diff --git a/.travis.yml b/.travis.yml index 62961abc4..a3571378a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,7 +41,7 @@ cache: before_install: - gem install bundler - - npm i -g eslint babel-eslint + - npm i -g eslint@1.3.1 babel-eslint - eslint app/assets/javascripts - eslint --ext .es6 app/assets/javascripts - eslint --ext .es6 test/javascripts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f00d5d078..98aa05e4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,129 +1,27 @@ # Contributing to Discourse -## Before You Start +## Important note for Developers -Anyone wishing to contribute to the **[Discourse/Discourse](https://github.com/discourse/discourse)** project **MUST read & sign the [Electronic Discourse Forums Contribution License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first. +Anyone wishing to contribute to the [github.com/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [Contributor License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first. -## Reporting Bugs +For more information on -1. Always update to the most recent master release; the bug may already be resolved. +- how to set up your development environment +- first-time project suggestions +- code conventions +- step-by-step guide for GitHub commits -2. Search for similar issues on the [Discourse meta forum][m]; it may already be an identified problem. +**please read our [Discourse Development Contribution Guidelines](https://meta.discourse.org/t/discourse-development-contribution-guidelines/3823)** -3. Make sure you can reproduce your problem on our sandbox at [try.discourse.org](http://try.discourse.org) +## Everything Else -4. If this is a bug or problem that **requires any kind of extended discussion -- open [a topic on meta][m] about it**. +There are many other ways to contribute to Discourse besides code. We've outlined the most common ones below. -5. If possible, submit a Pull Request with a failing test. If you'd rather take matters into your own hands, fix the bug yourself (jump down to the "Contributing (Step-by-step)" section). +- [Reporting Bugs](https://meta.discourse.org/t/how-to-make-bug-reports-for-discourse/33070) +- [Requesting Features](https://meta.discourse.org/t/how-to-request-new-features-for-discourse/32986) +- [Translation](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882) +- Documentation (TBA) -6. When the bug is fixed, we will do our best to update the Discourse topic. +For anything else, just start a new topic on [Meta](https://meta.discourse.org/) and let us know what you're interested in working on. -## Requesting New Features - -1. Do not submit a feature request on GitHub; all feature requests on GitHub will be closed. Instead, visit the **[Discourse meta forum, features category](http://meta.discourse.org/category/feature)**, and search this list for similar feature requests. It's possible somebody has already asked for this feature or provided a pull request that we're still discussing. - -2. Provide a clear and detailed explanation of the feature you want and why it's important to add. The feature must apply to a wide array of users of Discourse; for smaller, more targeted "one-off" features, you might consider writing a plugin for Discourse. You may also want to provide us with some advance documentation on the feature, which will help the community to better understand where it will fit. - -3. If you're a Rock Star programmer, build the feature yourself (refer to the "Contributing (Step-by-step)" section below). - -## Contributing (Step-by-step) - -1. Clone the Repo: - - git clone git://github.com/discourse/discourse.git - -2. Create a new Branch: - - cd discourse - git checkout -b new_discourse_branch - - > Please keep your code clean: one feature or bug-fix per branch. If you find another bug, you want to fix while being in a new branch, please fix it in a separated branch instead. - -3. Code - * Adhere to common conventions you see in the existing code - * Include tests, and ensure they pass - * Search to see if your new functionality has been discussed on [the Discourse meta forum](http://meta.discourse.org), and include updates as appropriate - -4. Follow the Coding Conventions - * two spaces, no tabs - * no trailing whitespaces, blank lines should have no spaces - * use spaces around operators, after commas, colons, semicolons, around `{` and before `}` - * no space after `(`, `[` or before `]`, `)` - * use Ruby 1.9 hash syntax: prefer `{ a: 1 }` over `{ :a => 1 }` - * prefer `class << self; def method; end` over `def self.method` for class methods - * prefer `{ ... }` over `do ... end` for single-line blocks, avoid using `{ ... }` for multi-line blocks - * avoid `return` when not required - - > However, please note that **pull requests consisting entirely of style changes are not welcome on this project**. Style changes in the context of pull requests that also refactor code, fix bugs, improve functionality *are* welcome. - -5. Commit - - For every commit please write a short (max 72 characters) summary in the first line followed with a blank line and then more detailed descriptions of the change. Use markdown syntax for simple styling. - - **NEVER leave the commit message blank!** Provide a detailed, clear, and complete description of your commit! - - -6. Update your branch - - ``` - git fetch origin - git rebase origin/master - ``` - -7. Fork - - ``` - git remote add mine git@github.com:<your user name>/discourse.git - ``` - -8. Push to your remote - - ``` - git push mine new_discourse_branch - ``` - -9. Issue a Pull Request - - Before submitting a pull-request, clean up the history, go over your commits and squash together minor changes and fixes into the corresponding commits. You can squash commits with the interactive rebase command: - - ``` - git fetch origin - git checkout new_discourse_branch - git rebase origin/master - git rebase -i - - < the editor opens and allows you to change the commit history > - < follow the instructions on the bottom of the editor > - - git push -f mine new_discourse_branch - ``` - - - In order to make a pull request, - * Navigate to the Discourse repository you just pushed to (e.g. https://github.com/your-user-name/discourse) - * Click "Pull Request". - * Write your branch name in the branch field (this is filled with "master" by default) - * Click "Update Commit Range". - * Ensure the changesets you introduced are included in the "Commits" tab. - * Ensure that the "Files Changed" incorporate all of your changes. - * Fill in some details about your potential patch including a meaningful title. - * Click "Send pull request". - - Thanks for that -- we'll get to your pull request ASAP, we love pull requests! - -10. Responding to Feedback - - The Discourse team may recommend adjustments to your code. Part of interacting with a healthy open-source community requires you to be open to learning new techniques and strategies; *don't get discouraged!* Remember: if the Discourse team suggest changes to your code, **they care enough about your work that they want to include it**, and hope that you can assist by implementing those revisions on your own. - - > Though we ask you to clean your history and squash commit before submitting a pull-request, please do not change any commits you've submitted already (as other work might be build on top). - -## Translations - -Translators can do their work in our [Transifex project](https://www.transifex.com/projects/p/discourse-org/). For more information, please see these how-to topics: - -* [Contributing a translation to Discourse](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882) -* [How to add a new language](https://meta.discourse.org/t/how-to-add-a-new-language/14970) - - - -[m]: http://meta.discourse.org +*Thanks for contributing!* diff --git a/Gemfile.lock b/Gemfile.lock index 44397f9df..e33b7a10e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -209,7 +209,7 @@ GEM omniauth-twitter (1.0.1) multi_json (~> 1.3) omniauth-oauth (~> 1.0) - onebox (1.5.24) + onebox (1.5.25) moneta (~> 0.8) multi_json (~> 1.11) mustache diff --git a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 index 837960646..539171bc9 100644 --- a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 @@ -1,3 +1,4 @@ +import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; export default Em.Component.extend(UploadMixin, { @@ -5,21 +6,23 @@ export default Em.Component.extend(UploadMixin, { tagName: "span", imageIsNotASquare: false, - uploadButtonText: function() { - return this.get("uploading") ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture"); - }.property("uploading"), + @computed("uploading") + uploadButtonText(uploading) { + return uploading ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture"); + }, uploadDone(upload) { this.setProperties({ imageIsNotASquare: upload.width !== upload.height, uploadedAvatarTemplate: upload.url, - custom_avatar_upload_id: upload.id, + uploadedAvatarId: upload.id, }); this.sendAction("done"); }, - data: function() { - return { user_id: this.get("user_id") }; - }.property("user_id") + @computed("user_id") + data(user_id) { + return { user_id }; + } }); diff --git a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 index 593a604e7..60c3f4ddc 100644 --- a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 @@ -7,16 +7,24 @@ export default buildCategoryPanel('security', { actions: { editPermissions() { - this.set('editingPermissions', true); + if (!this.get('category.is_special')) { + this.set('editingPermissions', true); + } }, addPermission(group, id) { - this.get('category').addPermission({group_name: group + "", - permission: Discourse.PermissionType.create({id})}); + if (!this.get('category.is_special')) { + this.get('category').addPermission({ + group_name: group + "", + permission: Discourse.PermissionType.create({id}) + }); + } }, removePermission(permission) { - this.get('category').removePermission(permission); + if (!this.get('category.is_special')) { + this.get('category').removePermission(permission); + } }, } }); diff --git a/app/assets/javascripts/discourse/components/menu-panel.js.es6 b/app/assets/javascripts/discourse/components/menu-panel.js.es6 index 8a5b2ea27..02304afee 100644 --- a/app/assets/javascripts/discourse/components/menu-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/menu-panel.js.es6 @@ -54,7 +54,7 @@ export default Ember.Component.extend({ } $panelBody.height('100%'); - this.$().css({ left: "auto", top: (menuTop) + "px", height }); + this.$().css({ top: menuTop + "px", height }); $('body').removeClass('drop-down-visible'); } diff --git a/app/assets/javascripts/discourse/components/search-menu.js.es6 b/app/assets/javascripts/discourse/components/search-menu.js.es6 index 8a4f2ddec..413525280 100644 --- a/app/assets/javascripts/discourse/components/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/components/search-menu.js.es6 @@ -1,4 +1,4 @@ -import {searchForTerm, searchContextDescription} from 'discourse/lib/search'; +import {searchForTerm, searchContextDescription, isValidSearchTerm } from 'discourse/lib/search'; import DiscourseURL from 'discourse/lib/url'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import showModal from 'discourse/lib/show-modal'; @@ -61,8 +61,8 @@ export default Ember.Component.extend({ @observes('searchService.term', 'typeFilter') newSearchNeeded() { this.set('noResults', false); - const term = (this.get('searchService.term') || '').trim(); - if (term.length >= Discourse.SiteSettings.min_search_term_length) { + const term = this.get('searchService.term') + if (isValidSearchTerm(term)) { this.set('loading', true); Ember.run.debounce(this, 'searchTerm', term, this.get('typeFilter'), 400); } else { @@ -134,7 +134,7 @@ export default Ember.Component.extend({ }, showedSearch() { - $('#search-term').focus(); + $('#search-term').focus().select(); }, showSearchHelp() { @@ -154,8 +154,7 @@ export default Ember.Component.extend({ }, keyDown(e) { - const term = this.get('searchService.term'); - if (e.which === 13 && term && term.length >= this.siteSettings.min_search_term_length) { + if (e.which === 13 && isValidSearchTerm(this.get('searchService.term'))) { this.set('visible', false); this.send('fullSearch'); } diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6 index bf46ddf33..80dcc0ba2 100644 --- a/app/assets/javascripts/discourse/components/search-text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6 @@ -5,5 +5,13 @@ export default TextField.extend({ @computed('searchService.searchContextEnabled') placeholder: function(searchContextEnabled) { return searchContextEnabled ? "" : I18n.t('search.title'); - } + }, + + focusIn: function() { + Em.run.later(() => { this.$().select(); }); + }, + + becomeFocused: function() { + if (this.get('hasAutofocus')) this.$().focus(); + }.on('didInsertElement') }); diff --git a/app/assets/javascripts/discourse/components/who-liked.js.es6 b/app/assets/javascripts/discourse/components/who-liked.js.es6 index 34ba67223..5c12a91d9 100644 --- a/app/assets/javascripts/discourse/components/who-liked.js.es6 +++ b/app/assets/javascripts/discourse/components/who-liked.js.es6 @@ -13,7 +13,7 @@ export default Ember.Component.extend(StringBuffer, { iconsHtml += "<a href=\"" + Discourse.getURL("/users/") + u.get('username_lower') + "\" data-user-card=\"" + u.get('username_lower') + "\">"; iconsHtml += Discourse.Utilities.avatarImg({ size: 'small', - avatarTemplate: u.get('avatarTemplate'), + avatarTemplate: u.get('avatar_template'), title: u.get('username') }); iconsHtml += "</a>"; diff --git a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 index f0bb9fcb3..29aae8bf6 100644 --- a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 +++ b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 @@ -1,21 +1,29 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import computed from "ember-addons/ember-computed-decorators"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; export default Ember.Controller.extend(ModalFunctionality, { - uploadedAvatarTemplate: null, - saveDisabled: Em.computed.alias("uploading"), - hasUploadedAvatar: Em.computed.or('uploadedAvatarTemplate', 'custom_avatar_upload_id'), - - selectedUploadId: function() { - switch (this.get("selected")) { - case "system": return this.get("system_avatar_upload_id"); - case "gravatar": return this.get("gravatar_avatar_upload_id"); - default: return this.get("custom_avatar_upload_id"); + @computed("selected", "system_avatar_upload_id", "gravatar_avatar_upload_id", "custom_avatar_upload_id") + selectedUploadId(selected, system, gravatar, custom) { + switch (selected) { + case "system": return system; + case "gravatar": return gravatar; + default: return custom; } - }.property('selected', 'system_avatar_upload_id', 'gravatar_avatar_upload_id', 'custom_avatar_upload_id'), + }, - allowImageUpload: function() { + @computed("selected", "system_avatar_template", "gravatar_avatar_template", "custom_avatar_template") + selectedAvatarTemplate(selected, system, gravatar, custom) { + switch (selected) { + case "system": return system; + case "gravatar": return gravatar; + default: return custom; + } + }, + + @computed() + allowImageUpload() { return Discourse.Utilities.allowsImages(); - }.property(), + }, actions: { useUploadedAvatar() { this.set("selected", "uploaded"); }, @@ -25,8 +33,11 @@ export default Ember.Controller.extend(ModalFunctionality, { refreshGravatar() { this.set("gravatarRefreshDisabled", true); return Discourse - .ajax("/user_avatar/" + this.get("username") + "/refresh_gravatar.json", { method: 'POST' }) - .then(result => this.set("gravatar_avatar_upload_id", result.upload_id)) + .ajax(`/user_avatar/${this.get("username")}/refresh_gravatar.json`, { method: "POST" }) + .then(result => this.setProperties({ + gravatar_avatar_template: result.gravatar_avatar_template, + gravatar_upload_id: result.gravatar_upload_id, + })) .finally(() => this.set("gravatarRefreshDisabled", false)); } } diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 843aae4ee..d6d1224f5 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -3,6 +3,7 @@ import DiscourseURL from 'discourse/lib/url'; import Quote from 'discourse/lib/quote'; import Draft from 'discourse/models/draft'; import Composer from 'discourse/models/composer'; +import computed from 'ember-addons/ember-computed-decorators'; function loadDraft(store, opts) { opts = opts || {}; @@ -64,6 +65,11 @@ export default Ember.Controller.extend({ this.set('similarTopics', []); }.on('init'), + @computed('model.action') + canWhisper(action) { + return this.siteSettings.enable_whispers && action === Composer.REPLY; + }, + showWarning: function() { if (!Discourse.User.currentProp('staff')) { return false; } @@ -132,7 +138,6 @@ export default Ember.Controller.extend({ }, hitEsc() { - const messages = this.get('controllers.composer-messages.model'); if (messages.length) { messages.popObject(); 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 fce0fecd8..f4fb1e787 100644 --- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 @@ -1,4 +1,4 @@ -import { translateResults, searchContextDescription, getSearchKey } from "discourse/lib/search"; +import { translateResults, searchContextDescription, getSearchKey, isValidSearchTerm } from "discourse/lib/search"; import showModal from 'discourse/lib/show-modal'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; import Category from 'discourse/models/category'; @@ -13,6 +13,11 @@ export default Ember.Controller.extend({ context_id: null, context: null, + @computed('q') + hasAutofocus(q) { + return Em.isEmpty(q); + }, + @computed('skip_context', 'context') searchContextEnabled: { get(skip,context){ @@ -37,7 +42,12 @@ export default Ember.Controller.extend({ @computed('q') searchActive(q){ - return q && q.length > 0; + return isValidSearchTerm(q); + }, + + @computed('searchTerm') + isNotValidSearchTerm(searchTerm) { + return !isValidSearchTerm(searchTerm); }, @observes('model') @@ -129,6 +139,7 @@ export default Ember.Controller.extend({ }, search() { + if (this.get("isNotValidSearchTerm")) return; this.search(); } } diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index ee3e86e9d..a7aa333fb 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -1,6 +1,7 @@ import { setting } from 'discourse/lib/computed'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; import { popupAjaxError } from 'discourse/lib/ajax-error'; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend(CanCheckEmails, { @@ -10,18 +11,18 @@ export default Ember.Controller.extend(CanCheckEmails, { allowBackgrounds: setting('allow_profile_backgrounds'), editHistoryVisible: setting('edit_history_visible_to_public'), - selectedCategories: function(){ - return [].concat(this.get("model.watchedCategories"), - this.get("model.trackedCategories"), - this.get("model.mutedCategories")); - }.property("model.watchedCategories", "model.trackedCategories", "model.mutedCategories"), + @computed("model.watchedCategories", "model.trackedCategories", "model.mutedCategories") + selectedCategories(watched, tracked, muted) { + return [].concat(watched, tracked, muted); + }, // By default we haven't saved anything saved: false, newNameInput: null, - userFields: function() { + @computed("model.user_fields.@each.value") + userFields() { let siteUserFields = this.site.get('user_fields'); if (!Ember.isEmpty(siteUserFields)) { const userFields = this.get('model.user_fields'); @@ -35,34 +36,37 @@ export default Ember.Controller.extend(CanCheckEmails, { return Ember.Object.create({ value, field }); }); } - }.property('model.user_fields.@each.value'), + }, cannotDeleteAccount: Em.computed.not('can_delete_account'), deleteDisabled: Em.computed.or('saving', 'deleting', 'cannotDeleteAccount'), canEditName: setting('enable_names'), - nameInstructions: function() { + @computed() + nameInstructions() { return I18n.t(Discourse.SiteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions'); - }.property(), + }, - canSelectTitle: function() { - return this.siteSettings.enable_badges && this.get('model.has_title_badges'); - }.property('model.badge_count'), + @computed("model.has_title_badges") + canSelectTitle(hasTitleBadges) { + return this.siteSettings.enable_badges && hasTitleBadges; + }, - canChangePassword: function() { + @computed() + canChangePassword() { return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins; - }.property(), + }, - canReceiveDigest: function() { + @computed() + canReceiveDigest() { return !this.siteSettings.disable_digest_emails; - }.property(), + }, - availableLocales: function() { - return this.siteSettings.available_locales.split('|').map( function(s) { - return {name: s, value: s}; - }); - }.property(), + @computed() + availableLocales() { + return this.siteSettings.available_locales.split('|').map(s => ({ name: s, value: s })); + }, digestFrequencies: [{ name: I18n.t('user.email_digests.daily'), value: 1 }, { name: I18n.t('user.email_digests.every_three_days'), value: 3 }, @@ -86,16 +90,16 @@ export default Ember.Controller.extend(CanCheckEmails, { { name: I18n.t('user.new_topic_duration.after_2_weeks'), value: 2 * 7 * 60 * 24 }, { name: I18n.t('user.new_topic_duration.last_here'), value: -2 }], - saveButtonText: function() { - return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('save'); - }.property('model.isSaving'), + @computed("model.isSaving") + saveButtonText(isSaving) { + return isSaving ? I18n.t('saving') : I18n.t('save'); + }, passwordProgress: null, actions: { save() { - const self = this; this.set('saved', false); const model = this.get('model'); @@ -113,28 +117,27 @@ export default Ember.Controller.extend(CanCheckEmails, { // Cook the bio for preview model.set('name', this.get('newNameInput')); - return model.save().then(function() { + return model.save().then(() => { if (Discourse.User.currentProp('id') === model.get('id')) { Discourse.User.currentProp('name', model.get('name')); } model.set('bio_cooked', Discourse.Markdown.cook(Discourse.Markdown.sanitize(model.get('bio_raw')))); - self.set('saved', true); + this.set('saved', true); }).catch(popupAjaxError); }, changePassword() { - const self = this; if (!this.get('passwordProgress')) { this.set('passwordProgress', I18n.t("user.change_password.in_progress")); - return this.get('model').changePassword().then(function() { + return this.get('model').changePassword().then(() => { // password changed - self.setProperties({ + this.setProperties({ changePasswordProgress: false, passwordProgress: I18n.t("user.change_password.success") }); - }, function() { + }).catch(() => { // password failed to change - self.setProperties({ + this.setProperties({ changePasswordProgress: false, passwordProgress: I18n.t("user.change_password.error") }); diff --git a/app/assets/javascripts/discourse/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6 index cac2ea1f0..2f4ea292c 100644 --- a/app/assets/javascripts/discourse/helpers/application.js.es6 +++ b/app/assets/javascripts/discourse/helpers/application.js.es6 @@ -1,37 +1,29 @@ import registerUnbound from 'discourse/helpers/register-unbound'; -import avatarTemplate from 'discourse/lib/avatar-template'; import { longDate, autoUpdatingRelativeAge, number } from 'discourse/lib/formatter'; const safe = Handlebars.SafeString; -Em.Handlebars.helper('bound-avatar', function(user, size, uploadId) { +Em.Handlebars.helper('bound-avatar', (user, size) => { if (Em.isEmpty(user)) { return new safe("<div class='avatar-placeholder'></div>"); } - const username = Em.get(user, 'username'); - if (arguments.length < 4) { uploadId = Em.get(user, 'uploaded_avatar_id'); } - const avatar = Em.get(user, 'avatar_template') || avatarTemplate(username, uploadId); - + const avatar = Em.get(user, 'avatar_template'); return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar })); -}, 'username', 'uploaded_avatar_id', 'avatar_template'); +}, 'username', 'avatar_template'); /* * Used when we only have a template */ -Em.Handlebars.helper('bound-avatar-template', function(at, size) { +Em.Handlebars.helper('bound-avatar-template', (at, size) => { return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: at })); }); -registerUnbound('raw-date', function(dt) { - return longDate(new Date(dt)); -}); +registerUnbound('raw-date', dt => longDate(new Date(dt))); -registerUnbound('age-with-tooltip', function(dt) { - return new safe(autoUpdatingRelativeAge(new Date(dt), {title: true})); -}); +registerUnbound('age-with-tooltip', dt => new safe(autoUpdatingRelativeAge(new Date(dt), {title: true}))); -registerUnbound('number', function(orig, params) { +registerUnbound('number', (orig, params) => { orig = parseInt(orig, 10); if (isNaN(orig)) { orig = 0; } diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 index 1ab668ffc..c5eac31ad 100644 --- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 +++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 @@ -1,24 +1,23 @@ import registerUnbound from 'discourse/helpers/register-unbound'; -import avatarTemplate from 'discourse/lib/avatar-template'; function renderAvatar(user, options) { options = options || {}; if (user) { - var username = Em.get(user, 'username'); - if (!username) { - if (!options.usernamePath) { return ''; } - username = Em.get(user, options.usernamePath); - } - var title; + const username = Em.get(user, options.usernamePath || 'username'); + const avatarTemplate = Em.get(user, options.avatarTemplatePath || 'avatar_template'); + + if (!username || !avatarTemplate) { return ''; } + + let title; if (!options.ignoreTitle) { // first try to get a title title = Em.get(user, 'title'); // if there was no title provided if (!title) { // try to retrieve a description - var description = Em.get(user, 'description'); + const description = Em.get(user, 'description'); // if a description has been provided if (description && description.length > 0) { // preprend the username before the description @@ -27,14 +26,11 @@ function renderAvatar(user, options) { } } - // this is simply done to ensure we cache images correctly - var uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id'); - return Discourse.Utilities.avatarImg({ size: options.imageSize, extraClasses: Em.get(user, 'extras') || options.extraClasses, title: title || username, - avatarTemplate: avatarTemplate(username, uploadedAvatarId) + avatarTemplate: avatarTemplate }); } else { return ''; diff --git a/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6 b/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6 index d74e5264e..763a06b2e 100644 --- a/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6 +++ b/app/assets/javascripts/discourse/initializers/click-interceptor.js.es6 @@ -1,8 +1,10 @@ import interceptClick from 'discourse/lib/intercept-click'; +import DiscourseURL from 'discourse/lib/url'; export default { name: "click-interceptor", initialize() { $('#main').on('click.discourse', 'a', interceptClick); + $(window).on('hashchange', () => DiscourseURL.routeTo(document.location.hash)); } }; diff --git a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 b/app/assets/javascripts/discourse/lib/avatar-template.js.es6 deleted file mode 100644 index 542e97959..000000000 --- a/app/assets/javascripts/discourse/lib/avatar-template.js.es6 +++ /dev/null @@ -1,32 +0,0 @@ -import { hashString } from 'discourse/lib/hash'; - -let _splitAvatars; - -function defaultAvatar(username) { - const defaultAvatars = Discourse.SiteSettings.default_avatars; - if (defaultAvatars && defaultAvatars.length) { - _splitAvatars = _splitAvatars || defaultAvatars.split("\n"); - - if (_splitAvatars.length) { - const hash = hashString(username); - return _splitAvatars[Math.abs(hash) % _splitAvatars.length]; - } - } - - return Discourse.getURLWithCDN("/letter_avatar/" + - username.toLowerCase() + - "/{size}/" + - Discourse.LetterAvatarVersion + ".png"); -} - -export default function(username, uploadedAvatarId) { - if (uploadedAvatarId) { - return Discourse.getURLWithCDN("/user_avatar/" + - Discourse.BaseUrl + - "/" + - username.toLowerCase() + - "/{size}/" + - uploadedAvatarId + ".png"); - } - return defaultAvatar(username); -} diff --git a/app/assets/javascripts/discourse/lib/key-value-store.js.es6 b/app/assets/javascripts/discourse/lib/key-value-store.js.es6 index 30f86b16e..243146833 100644 --- a/app/assets/javascripts/discourse/lib/key-value-store.js.es6 +++ b/app/assets/javascripts/discourse/lib/key-value-store.js.es6 @@ -32,6 +32,7 @@ KeyValueStore.prototype = { }, remove(key) { + if (!safeLocalStorage) { return; } return safeLocalStorage.removeItem(this.context + key); }, diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6 index 21fd8afeb..9d7bec2d6 100644 --- a/app/assets/javascripts/discourse/lib/search.js.es6 +++ b/app/assets/javascripts/discourse/lib/search.js.es6 @@ -106,4 +106,12 @@ const getSearchKey = function(args){ ((args.searchContext && args.searchContext.id) || "") }; -export { searchForTerm, searchContextDescription, getSearchKey }; +const isValidSearchTerm = function(searchTerm) { + if (searchTerm) { + return searchTerm.trim().length >= Discourse.SiteSettings.min_search_term_length; + } else { + return false; + } +}; + +export { searchForTerm, searchContextDescription, getSearchKey, isValidSearchTerm }; diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index f94c4c2bd..7f2d1bc6c 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -105,7 +105,7 @@ const DiscourseURL = Ember.Object.createWithMixins({ It contains the logic necessary to route within a topic using replaceState to keep the history intact. **/ - routeTo: function(path, opts) { + routeTo(path, opts) { if (Em.isEmpty(path)) { return; } if (Discourse.get('requiresRefresh')) { @@ -122,6 +122,7 @@ const DiscourseURL = Ember.Object.createWithMixins({ // Scroll to the same page, different anchor if (path.indexOf('#') === 0) { this.scrollToId(path); + history.replaceState(undefined, undefined, path); return; } diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 12cba4936..576bec397 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -24,6 +24,7 @@ const CLOSED = 'closed', category: 'categoryId', topic_id: 'topic.id', is_warning: 'isWarning', + whisper: 'whisper', archetype: 'archetypeId', target_usernames: 'targetUsernames', typing_duration_msecs: 'typingTime', @@ -557,6 +558,9 @@ const Composer = RestModel.extend({ let addedToStream = false; + const postTypes = this.site.get('post_types'); + const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular; + // Build the post object const createdPost = this.store.createRecord('post', { imageSizes: opts.imageSizes, @@ -567,9 +571,9 @@ const Composer = RestModel.extend({ username: user.get('username'), user_id: user.get('id'), user_title: user.get('title'), - uploaded_avatar_id: user.get('uploaded_avatar_id'), + avatar_template: user.get('avatar_template'), user_custom_fields: user.get('custom_fields'), - post_type: this.site.get('post_types.regular'), + post_type: postType, actions_summary: [], moderator: user.get('moderator'), admin: user.get('admin'), @@ -587,7 +591,7 @@ const Composer = RestModel.extend({ reply_to_post_number: post.get('post_number'), reply_to_user: { username: post.get('username'), - uploaded_avatar_id: post.get('uploaded_avatar_id') + avatar_template: post.get('avatar_template') } }); } diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index c332836f6..6ba2606aa 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -1,7 +1,7 @@ import RestModel from 'discourse/models/rest'; import { popupAjaxError } from 'discourse/lib/ajax-error'; import ActionSummary from 'discourse/models/action-summary'; -import { url, fmt, propertyEqual } from 'discourse/lib/computed'; +import { url, propertyEqual } from 'discourse/lib/computed'; import Quote from 'discourse/lib/quote'; import computed from 'ember-addons/ember-computed-decorators'; @@ -77,7 +77,6 @@ const Post = RestModel.extend({ topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'), hasHistory: Em.computed.gt('version', 1), - postElementId: fmt('post_number', 'post_%@'), canViewRawEmail: function() { return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff'); diff --git a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 index ded0d824c..11c8d67fe 100644 --- a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 @@ -174,8 +174,8 @@ const TopicTrackingState = Discourse.Model.extend({ if (filter === "new") { list.topics.splice(i, 1); } else { - list.topics[i].unseen = false; - list.topics[i].dont_sync = true; + list.topics[i].set('unseen', false); + list.topics[i].set('dont_sync', true); } } } diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6 index 2d273c43e..f03d81908 100644 --- a/app/assets/javascripts/discourse/models/user-action.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action.js.es6 @@ -1,5 +1,7 @@ import RestModel from 'discourse/models/rest'; import { url } from 'discourse/lib/computed'; +import { on } from 'ember-addons/ember-computed-decorators'; +import computed from 'ember-addons/ember-computed-decorators'; const UserActionTypes = { likes_given: 1, @@ -17,21 +19,22 @@ const UserActionTypes = { }; const InvertedActionTypes = {}; -_.each(UserActionTypes, function (k, v) { +_.each(UserActionTypes, (k, v) => { InvertedActionTypes[k] = v; }); const UserAction = RestModel.extend({ - _attachCategory: function() { + @on("init") + _attachCategory() { const categoryId = this.get('category_id'); if (categoryId) { this.set('category', Discourse.Category.findById(categoryId)); } - }.on('init'), + }, - descriptionKey: function() { - const action = this.get('action_type'); + @computed("action_type") + descriptionKey(action) { if (action === null || Discourse.UserAction.TO_SHOW.indexOf(action) >= 0) { if (this.get('isPM')) { return this.get('sameUser') ? 'sent_by_you' : 'sent_by_user'; @@ -59,34 +62,39 @@ const UserAction = RestModel.extend({ return this.get('targetUser') ? 'user_mentioned_you' : 'user_mentioned_user'; } } - }.property('action_type'), + }, - sameUser: function() { - return this.get('username') === Discourse.User.currentProp('username'); - }.property('username'), + @computed("username") + sameUser(username) { + return username === Discourse.User.currentProp('username'); + }, - targetUser: function() { - return this.get('target_username') === Discourse.User.currentProp('username'); - }.property('target_username'), + @computed("target_username") + targetUser(targetUsername) { + return targetUsername === Discourse.User.currentProp('username'); + }, presentName: Em.computed.any('name', 'username'), targetDisplayName: Em.computed.any('target_name', 'target_username'), actingDisplayName: Em.computed.any('acting_name', 'acting_username'), targetUserUrl: url('target_username', '/users/%@'), - usernameLower: function() { - return this.get('username').toLowerCase(); - }.property('username'), + @computed("username") + usernameLower(username) { + return username.toLowerCase(); + }, userUrl: url('usernameLower', '/users/%@'), - postUrl: function() { + @computed() + postUrl() { return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('post_number')); - }.property(), + }, - replyUrl: function() { + @computed() + replyUrl() { return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('reply_to_post_number')); - }.property(), + }, replyType: Em.computed.equal('action_type', UserActionTypes.replies), postType: Em.computed.equal('action_type', UserActionTypes.posts), @@ -99,7 +107,7 @@ const UserAction = RestModel.extend({ postReplyType: Em.computed.or('postType', 'replyType'), removableBookmark: Em.computed.and('bookmarkType', 'sameUser'), - addChild: function(action) { + addChild(action) { let groups = this.get("childGroups"); if (!groups) { groups = { @@ -143,22 +151,21 @@ const UserAction = RestModel.extend({ "childGroups.edits.items", "childGroups.edits.items.@each", "childGroups.bookmarks.items", "childGroups.bookmarks.items.@each"), - switchToActing: function() { + switchToActing() { this.setProperties({ username: this.get('acting_username'), - uploaded_avatar_id: this.get('acting_uploaded_avatar_id'), name: this.get('actingDisplayName') }); } }); UserAction.reopenClass({ - collapseStream: function(stream) { + collapseStream(stream) { const uniq = {}; const collapsed = []; let pos = 0; - stream.forEach(function(item) { + stream.forEach(item => { const key = "" + item.topic_id + "-" + item.post_number; const found = uniq[key]; if (found === void 0) { diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 01aef870a..bd0e36c5f 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -1,11 +1,11 @@ import { url } from 'discourse/lib/computed'; import RestModel from 'discourse/models/rest'; -import avatarTemplate from 'discourse/lib/avatar-template'; import UserStream from 'discourse/models/user-stream'; import UserPostsStream from 'discourse/models/user-posts-stream'; import Singleton from 'discourse/mixins/singleton'; import { longDate } from 'discourse/lib/formatter'; import computed from 'ember-addons/ember-computed-decorators'; +import { observes } from 'ember-addons/ember-computed-decorators'; import Badge from 'discourse/models/badge'; import UserBadge from 'discourse/models/user-badge'; @@ -18,13 +18,15 @@ const User = RestModel.extend({ hasNotPosted: Em.computed.not("hasPosted"), canBeDeleted: Em.computed.and("can_be_deleted", "hasNotPosted"), - stream: function() { + @computed() + stream() { return UserStream.create({ user: this }); - }.property(), + }, - postsStream: function() { + @computed() + postsStream() { return UserPostsStream.create({ user: this }); - }.property(), + }, staff: Em.computed.or('admin', 'moderator'), @@ -32,27 +34,22 @@ const User = RestModel.extend({ return Discourse.ajax(`/session/${this.get('username')}`, { type: 'DELETE'}); }, - searchContext: function() { + @computed("username_lower") + searchContext(username) { return { type: 'user', - id: this.get('username_lower'), + id: username, user: this }; - }.property('username_lower'), + }, - /** - This user's display name. Returns the name if possible, otherwise returns the - username. - - @property displayName - @type {String} - **/ - displayName: function() { - if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(this.get('name'))) { - return this.get('name'); + @computed("username", "name") + displayName(username, name) { + if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(name)) { + return name; } - return this.get('username'); - }.property('username', 'name'), + return username; + }, @computed('profile_background') profileBackground(bgUrl) { @@ -60,38 +57,23 @@ const User = RestModel.extend({ return ('background-image: url(' + Discourse.getURLWithCDN(bgUrl) + ')').htmlSafe(); }, - path: function(){ - return Discourse.getURL('/users/' + this.get('username_lower')); + @computed() + path() { // no need to observe, requires a hard refresh to update - }.property(), + return Discourse.getURL(`/users/${this.get('username_lower')}`); + }, - /** - Path to this user's administration - - @property adminPath - @type {String} - **/ adminPath: url('username_lower', "/admin/users/%@"), - /** - This user's username in lowercase. + @computed("username") + username_lower(username) { + return username.toLowerCase(); + }, - @property username_lower - @type {String} - **/ - username_lower: function() { - return this.get('username').toLowerCase(); - }.property('username'), - - /** - This user's trust level. - - @property trustLevel - @type {Integer} - **/ - trustLevel: function() { - return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(this.get('trust_level'), 10)); - }.property('trust_level'), + @computed("trust_level") + trustLevel(trustLevel) { + return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(trustLevel, 10)); + }, isBasic: Em.computed.equal('trust_level', 0), isLeader: Em.computed.equal('trust_level', 3), @@ -100,61 +82,36 @@ const User = RestModel.extend({ isSuspended: Em.computed.equal('suspended', true), - suspended: function() { - return this.get('suspended_till') && moment(this.get('suspended_till')).isAfter(); - }.property('suspended_till'), + @computed("suspended_till") + suspended(suspendedTill) { + return suspendedTill && moment(suspendedTill).isAfter(); + }, - suspendedTillDate: function() { - return longDate(this.get('suspended_till')); - }.property('suspended_till'), + @computed("suspended_till") + suspendedTillDate(suspendedTill) { + return longDate(suspendedTill); + }, - /** - Changes this user's username. - - @method changeUsername - @param {String} newUsername The user's new username - @returns Result of ajax call - **/ - changeUsername: function(newUsername) { - return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/username", { + changeUsername(new_username) { + return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/username`, { type: 'PUT', - data: { new_username: newUsername } + data: { new_username } }); }, - /** - Changes this user's email address. - - @method changeEmail - @param {String} email The user's new email address\ - @returns Result of ajax call - **/ - changeEmail: function(email) { - return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/email", { + changeEmail(email) { + return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/email`, { type: 'PUT', - data: { email: email } + data: { email } }); }, - /** - Returns a copy of this user. - - @method copy - @returns {User} - **/ - copy: function() { + copy() { return Discourse.User.create(this.getProperties(Ember.keys(this))); }, - /** - Save's this user's properties over AJAX via a PUT request. - - @method save - @returns {Promise} the result of the operation - **/ - save: function() { - const self = this, - data = this.getProperties( + save() { + const data = this.getProperties( 'auto_track_topics_after_msecs', 'bio_raw', 'website', @@ -179,10 +136,10 @@ const User = RestModel.extend({ 'card_background' ); - ['muted','watched','tracked'].forEach(function(s){ - var cats = self.get(s + 'Categories').map(function(c){ return c.get('id')}); + ['muted','watched','tracked'].forEach(s => { + let cats = this.get(s + 'Categories').map(c => c.get('id')); // HACK: denote lack of categories - if(cats.length === 0) { cats = [-1]; } + if (cats.length === 0) { cats = [-1]; } data[s + '_category_ids'] = cats; }); @@ -192,26 +149,19 @@ const User = RestModel.extend({ // TODO: We can remove this when migrated fully to rest model. this.set('isSaving', true); - return Discourse.ajax("/users/" + this.get('username_lower'), { + return Discourse.ajax(`/users/${this.get('username_lower')}`, { data: data, type: 'PUT' - }).then(function(result) { - self.set('bio_excerpt', result.user.bio_excerpt); - - const userProps = self.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon'); + }).then(result => { + this.set('bio_excerpt', result.user.bio_excerpt); + const userProps = this.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon'); Discourse.User.current().setProperties(userProps); }).finally(() => { this.set('isSaving', false); }); }, - /** - Changes the password and calls the callback function on AJAX.complete. - - @method changePassword - @returns {Promise} the result of the change password operation - **/ - changePassword: function() { + changePassword() { return Discourse.ajax("/session/forgot_password", { dataType: 'json', data: { login: this.get('username') }, @@ -219,73 +169,63 @@ const User = RestModel.extend({ }); }, - /** - Loads a single user action by id. - - @method loadUserAction - @param {Integer} id The id of the user action being loaded - @returns A stream of the user's actions containing the action of id - **/ - loadUserAction: function(id) { - var self = this, - stream = this.get('stream'); - return Discourse.ajax("/user_actions/" + id + ".json", { cache: 'false' }).then(function(result) { + loadUserAction(id) { + const stream = this.get('stream'); + return Discourse.ajax(`/user_actions/${id}.json`, { cache: 'false' }).then(result => { if (result && result.user_action) { - var ua = result.user_action; + const ua = result.user_action; - if ((self.get('stream.filter') || ua.action_type) !== ua.action_type) return; - if (!self.get('stream.filter') && !self.inAllStream(ua)) return; + if ((this.get('stream.filter') || ua.action_type) !== ua.action_type) return; + if (!this.get('stream.filter') && !this.inAllStream(ua)) return; - var action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]); + const action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]); stream.set('itemsLoaded', stream.get('itemsLoaded') + 1); stream.get('content').insertAt(0, action[0]); } }); }, - inAllStream: function(ua) { + inAllStream(ua) { return ua.action_type === Discourse.UserAction.TYPES.posts || ua.action_type === Discourse.UserAction.TYPES.topics; }, // The user's stat count, excluding PMs. - statsCountNonPM: function() { - var self = this; - + @computed("statsExcludingPms.@each.count") + statsCountNonPM() { if (Ember.isEmpty(this.get('statsExcludingPms'))) return 0; - var count = 0; - _.each(this.get('statsExcludingPms'), function(val) { - if (self.inAllStream(val)){ + let count = 0; + _.each(this.get('statsExcludingPms'), val => { + if (this.inAllStream(val)) { count += val.count; } }); return count; - }.property('statsExcludingPms.@each.count'), + }, // The user's stats, excluding PMs. - statsExcludingPms: function() { + @computed("stats.@each.isPM") + statsExcludingPms() { if (Ember.isEmpty(this.get('stats'))) return []; return this.get('stats').rejectProperty('isPM'); - }.property('stats.@each.isPM'), + }, - findDetails: function(options) { - var user = this; + findDetails(options) { + const user = this; - return PreloadStore.getAndRemove("user_" + user.get('username'), function() { - return Discourse.ajax("/users/" + user.get('username') + '.json', {data: options}); - }).then(function (json) { + return PreloadStore.getAndRemove(`user_${user.get('username')}`, () => { + return Discourse.ajax(`/users/${user.get('username')}.json`, { data: options }); + }).then(json => { if (!Em.isEmpty(json.user.stats)) { - json.user.stats = Discourse.User.groupStats(_.map(json.user.stats,function(s) { + json.user.stats = Discourse.User.groupStats(_.map(json.user.stats, s => { if (s.count) s.count = parseInt(s.count, 10); return Discourse.UserActionStat.create(s); })); } if (!Em.isEmpty(json.user.custom_groups)) { - json.user.custom_groups = json.user.custom_groups.map(function (g) { - return Discourse.Group.create(g); - }); + json.user.custom_groups = json.user.custom_groups.map(g => Discourse.Group.create(g)); } if (json.user.invited_by) { @@ -294,12 +234,10 @@ const User = RestModel.extend({ if (!Em.isEmpty(json.user.featured_user_badge_ids)) { const userBadgesMap = {}; - UserBadge.createFromJson(json).forEach(function(userBadge) { + UserBadge.createFromJson(json).forEach(userBadge => { userBadgesMap[ userBadge.get('id') ] = userBadge; }); - json.user.featured_user_badges = json.user.featured_user_badge_ids.map(function(id) { - return userBadgesMap[id]; - }); + json.user.featured_user_badges = json.user.featured_user_badge_ids.map(id => userBadgesMap[id]); } if (json.user.card_badge) { @@ -311,81 +249,62 @@ const User = RestModel.extend({ }); }, - findStaffInfo: function() { + findStaffInfo() { if (!Discourse.User.currentProp("staff")) { return Ember.RSVP.resolve(null); } - var self = this; - return Discourse.ajax("/users/" + this.get("username_lower") + "/staff-info.json").then(function(info) { - self.setProperties(info); + return Discourse.ajax(`/users/${this.get("username_lower")}/staff-info.json`).then(info => { + this.setProperties(info); }); }, - avatarTemplate: function() { - return avatarTemplate(this.get('username'), this.get('uploaded_avatar_id')); - }.property('uploaded_avatar_id', 'username'), - - /* - Change avatar selection - */ - pickAvatar: function(uploadId) { - var self = this; - - return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/pick", { + pickAvatar(upload_id, type, avatar_template) { + return Discourse.ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, { type: 'PUT', - data: { upload_id: uploadId } - }).then(function(){ - self.set('uploaded_avatar_id', uploadId); - }); + data: { upload_id, type } + }).then(() => this.setProperties({ + avatar_template, + uploaded_avatar_id: upload_id + })); }, - /** - Determines whether the current user is allowed to upload a file. - - @method isAllowedToUploadAFile - @param {String} type The type of the upload (image, attachment) - @returns true if the current user is allowed to upload a file - **/ - isAllowedToUploadAFile: function(type) { + isAllowedToUploadAFile(type) { return this.get('staff') || this.get('trust_level') > 0 || Discourse.SiteSettings['newuser_max_' + type + 's'] > 0; }, - /** - Invite a user to the site - - @method createInvite - @param {String} email The email address of the user to invite to the site - @returns {Promise} the result of the server call - **/ - createInvite: function(email, groupNames) { + createInvite(email, group_names) { return Discourse.ajax('/invites', { type: 'POST', - data: {email: email, group_names: groupNames} + data: { email, group_names } }); }, - generateInviteLink: function(email, groupNames, topicId) { + generateInviteLink(email, group_names, topic_id) { return Discourse.ajax('/invites/link', { type: 'POST', - data: {email: email, group_names: groupNames, topic_id: topicId} + data: { email, group_names, topic_id } }); }, - updateMutedCategories: function() { + @observes("muted_category_ids") + updateMutedCategories() { this.set("mutedCategories", Discourse.Category.findByIds(this.muted_category_ids)); - }.observes("muted_category_ids"), + }, - updateTrackedCategories: function() { + @observes("tracked_category_ids") + updateTrackedCategories() { this.set("trackedCategories", Discourse.Category.findByIds(this.tracked_category_ids)); - }.observes("tracked_category_ids"), + }, - updateWatchedCategories: function() { + @observes("watched_category_ids") + updateWatchedCategories() { this.set("watchedCategories", Discourse.Category.findByIds(this.watched_category_ids)); - }.observes("watched_category_ids"), + }, - canDeleteAccount: function() { - return !Discourse.SiteSettings.enable_sso && this.get('can_delete_account') && ((this.get('reply_count')||0) + (this.get('topic_count')||0)) <= 1; - }.property('can_delete_account', 'reply_count', 'topic_count'), + @computed("can_delete_account", "reply_count", "topic_count") + canDeleteAccount(canDeleteAccount, replyCount, topicCount) { + return !Discourse.SiteSettings.enable_sso && canDeleteAccount && ((replyCount || 0) + (topicCount || 0)) <= 1; + }, "delete": function() { if (this.get('can_delete_account')) { @@ -398,27 +317,26 @@ const User = RestModel.extend({ } }, - dismissBanner: function (bannerKey) { + dismissBanner(bannerKey) { this.set("dismissed_banner_key", bannerKey); - Discourse.ajax("/users/" + this.get('username'), { + Discourse.ajax(`/users/${this.get('username')}`, { type: 'PUT', data: { dismissed_banner_key: bannerKey } }); }, - checkEmail: function () { - var self = this; - return Discourse.ajax("/users/" + this.get("username_lower") + "/emails.json", { + checkEmail() { + return Discourse.ajax(`/users/${this.get("username_lower")}/emails.json`, { type: "PUT", data: { context: window.location.pathname } - }).then(function (result) { + }).then(result => { if (result) { - self.setProperties({ + this.setProperties({ email: result.email, associated_accounts: result.associated_accounts }); } - }, function () {}); + }); } }); @@ -426,14 +344,14 @@ const User = RestModel.extend({ User.reopenClass(Singleton, { // Find a `Discourse.User` for a given username. - findByUsername: function(username, options) { + findByUsername(username, options) { const user = User.create({username: username}); return user.findDetails(options); }, // TODO: Use app.register and junk Singleton - createCurrent: function() { - var userJson = PreloadStore.get('currentUser'); + createCurrent() { + const userJson = PreloadStore.get('currentUser'); if (userJson) { const store = Discourse.__container__.lookup('store:main'); return store.createRecord('user', userJson); @@ -441,56 +359,38 @@ User.reopenClass(Singleton, { return null; }, - /** - Checks if given username is valid for this email address - - @method checkUsername - @param {String} username A username to check - @param {String} email An email address to check - @param {Number} forUserId user id - provide when changing username - **/ - checkUsername: function(username, email, forUserId) { + checkUsername(username, email, for_user_id) { return Discourse.ajax('/users/check_username', { - data: { username: username, email: email, for_user_id: forUserId } + data: { username, email, for_user_id } }); }, - /** - Groups the user's statistics - - @method groupStats - @param {Array} stats Given stats - @returns {Object} - **/ - groupStats: function(stats) { - var responses = Discourse.UserActionStat.create({ + groupStats(stats) { + const responses = Discourse.UserActionStat.create({ count: 0, action_type: Discourse.UserAction.TYPES.replies }); - stats.filterProperty('isResponse').forEach(function (stat) { + stats.filterProperty('isResponse').forEach(stat => { responses.set('count', responses.get('count') + stat.get('count')); }); - var result = Em.A(); + const result = Em.A(); result.pushObjects(stats.rejectProperty('isResponse')); - var insertAt = 0; - result.forEach(function(item, index){ - if(item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts){ + let insertAt = 0; + result.forEach((item, index) => { + if (item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts) { insertAt = index + 1; } }); - if(responses.count > 0) { + if (responses.count > 0) { result.insertAt(insertAt, responses); } - return(result); + return result; }, - /** - Creates a new account - **/ - createAccount: function(attrs) { + createAccount(attrs) { return Discourse.ajax("/users", { data: { name: attrs.accountName, diff --git a/app/assets/javascripts/discourse/routes/full-page-search.js.es6 b/app/assets/javascripts/discourse/routes/full-page-search.js.es6 index 10072d99f..3464250a7 100644 --- a/app/assets/javascripts/discourse/routes/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/routes/full-page-search.js.es6 @@ -1,4 +1,4 @@ -import { translateResults, getSearchKey } from "discourse/lib/search"; +import { translateResults, getSearchKey, isValidSearchTerm } from "discourse/lib/search"; export default Discourse.Route.extend({ queryParams: { q: {}, context_id: {}, context: {} }, @@ -23,7 +23,7 @@ export default Discourse.Route.extend({ } return PreloadStore.getAndRemove("search", function() { - if (params.q && params.q.length > 2) { + if (isValidSearchTerm(params.q)) { return Discourse.ajax("/search", { data: args }); } else { return null; diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6 index d748689f4..3ae6aed00 100644 --- a/app/assets/javascripts/discourse/routes/preferences.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences.js.es6 @@ -18,50 +18,52 @@ export default RestrictedUserRoute.extend({ showModal('avatar-selector'); // all the properties needed for displaying the avatar selector modal - const controller = this.controllerFor('avatar-selector'), - props = this.modelFor('user').getProperties( + const props = this.modelFor('user').getProperties( 'id', 'email', 'username', - 'uploaded_avatar_id', + 'avatar_template', + 'system_avatar_template', + 'gravatar_avatar_template', + 'custom_avatar_template', 'system_avatar_upload_id', 'gravatar_avatar_upload_id', 'custom_avatar_upload_id' ); - switch (props.uploaded_avatar_id) { - case props.system_avatar_upload_id: + switch (props.avatar_template) { + case props.system_avatar_template: props.selected = "system"; break; - case props.gravatar_avatar_upload_id: + case props.gravatar_avatar_template: props.selected = "gravatar"; break; default: props.selected = "uploaded"; } - controller.setProperties(props); + this.controllerFor('avatar-selector').setProperties(props); }, saveAvatarSelection() { const user = this.modelFor('user'), - avatarSelector = this.controllerFor('avatar-selector'); + controller = this.controllerFor('avatar-selector'), + selectedUploadId = controller.get("selectedUploadId"), + selectedAvatarTemplate = controller.get("selectedAvatarTemplate"), + type = controller.get("selected"); - // sends the information to the server if it has changed - if (avatarSelector.get('selectedUploadId') !== user.get('uploaded_avatar_id')) { - user.pickAvatar(avatarSelector.get('selectedUploadId')) - .then(() => { - user.setProperties(avatarSelector.getProperties( - 'system_avatar_upload_id', - 'gravatar_avatar_upload_id', - 'custom_avatar_upload_id' - )); - bootbox.alert(I18n.t("user.change_avatar.cache_notice")); - }); - } + user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate) + .then(() => { + user.setProperties(controller.getProperties( + 'system_avatar_template', + 'gravatar_avatar_template', + 'custom_avatar_template' + )); + bootbox.alert(I18n.t("user.change_avatar.cache_notice")); + }); // saves the data back - avatarSelector.send('closeModal'); + controller.send('closeModal'); }, } diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs index 02361e3cf..1b323792a 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-security.hbs @@ -1,4 +1,7 @@ <section class='field'> + {{#if category.is_special}} + <p class="warning">{{i18n 'category.special_warning'}}</p> + {{/if}} <ul class='permission-list'> {{#each category.permissions as |p|}} <li> @@ -16,6 +19,8 @@ {{view 'select' class="permission-selector" optionValuePath="content.id" optionLabelPath="content.description" content=category.availablePermissions value=selectedPermission}} <button {{action "addPermission" selectedGroup selectedPermission}} class="btn btn-small">{{i18n 'category.add_permission'}}</button> {{else}} - <button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button> + {{#unless category.is_special}} + <button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button> + {{/unless}} {{/if}} </section> diff --git a/app/assets/javascripts/discourse/templates/components/stream-item.hbs b/app/assets/javascripts/discourse/templates/components/stream-item.hbs index c84082519..22900bbb2 100644 --- a/app/assets/javascripts/discourse/templates/components/stream-item.hbs +++ b/app/assets/javascripts/discourse/templates/components/stream-item.hbs @@ -23,7 +23,7 @@ {{fa-icon 'times'}} {{i18n "bookmarks.remove"}} </button> {{else}} - <a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}</div></a> + <a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true" avatarTemplatePath="acting_avatar_template"}}</div></a> {{#if grandChild.edit_reason}} — <span class="edit-reason">{{grandChild.edit_reason}}</span>{{/if}} {{/if}} {{/each}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 711996431..362df1279 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -60,6 +60,16 @@ {{/unless}} </div> {{/if}} + + {{#if canWhisper}} + <div class='form-element clearfix'> + <label> + {{input type="checkbox" checked=model.whisper tabindex="3"}} + {{i18n "composer.add_whisper"}} + </label> + </div> + {{/if}} + {{plugin-outlet "composer-fields"}} </div> diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs index db60849ff..8f1874345 100644 --- a/app/assets/javascripts/discourse/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs @@ -1,6 +1,6 @@ <div class="search row clearfix"> - {{input type="text" value=searchTerm class="input-xxlarge search no-blur" action="search"}} - {{d-button action="search" icon="search" class="btn-primary"}} + {{search-text-field value=searchTerm class="input-xxlarge search no-blur" action="search" hasAutofocus=hasAutofocus}} + {{d-button action="search" icon="search" class="btn-primary" disabled=isNotValidSearchTerm}} {{#if canBulkSelect}} {{#if model.posts}} {{d-button icon="list" class="bulk-select" title="topics.bulk.toggle" action="toggleBulkSelect"}} diff --git a/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs new file mode 100644 index 000000000..6ac240e75 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/list/post-count-or-badges.raw.hbs @@ -0,0 +1,5 @@ +{{#if view.showBadges}} + {{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}} +{{else}} + {{raw "list/posts-count-column" topic=topic tagName="div"}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs index 1b837fb5a..5adbfd3d7 100644 --- a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs @@ -1,5 +1,5 @@ <td class='posters'> {{#each poster in posters}} -<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster usernamePath="user.username" imageSize="small"}}</a> +<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" imageSize="small"}}</a> {{/each}} </td> diff --git a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs index 611d012cc..06af1df5d 100644 --- a/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/components/basic-topic-list.hbs @@ -5,13 +5,9 @@ {{#each t in topics}} <tr {{bind-attr class="t.archived"}}> <td> - <div class='main-link clearfix'> + <div class='main-link'> {{topic-status topic=t}} {{topic-link t}} - {{topic-post-badges unread=t.unread - newPosts=t.new_posts - unseen=t.unseen - url=t.lastUnreadUrl}} {{#if t.hasExcerpt}} <div class="topic-excerpt"> @@ -25,10 +21,14 @@ </div> {{/if}} </div> + <div class='pull-right'> + {{raw "list/post-count-or-badges" topic=t postBadgesEnabled="true"}} + </div> + <div class='clearfix'></div> <div class="topic-item-stats clearfix"> <div class="pull-right"> - {{raw "list/posts-count-column" topic=t tagName="div"}} {{raw "list/activity-column" topic=t tagName="div" class="num activity last"}} + <a href="{{t.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date t.bumped_at}}}'>{{t.last_poster_username}}</a> </div> {{#unless controller.hideCategory}} <div class='category'> diff --git a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs index 01937421a..aa4370c96 100644 --- a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs @@ -2,13 +2,10 @@ <div class='main-link'> {{raw "topic-status" topic=content}} {{topic-link content}} - {{#if controller.showTopicPostBadges}} - {{raw "topic-post-badges" unread=content.unread newPosts=content.displayNewPosts unseen=content.unseen url=content.lastUnreadUrl}} - {{/if}} {{raw "list/topic-excerpt" topic=content}} </div> <div class='pull-right'> - {{raw "list/posts-count-column" topic=content tagName="div"}} + {{raw "list/post-count-or-badges" topic=content postBadgesEnabled=controller.showTopicPostBadges}} </div> <div class="clearfix"></div> @@ -22,8 +19,8 @@ <div class="pull-right"> <div class='num activity last'> - <a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a> {{raw "list/activity-column" topic=content tagName="span" class="age"}} + <a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a> </div> </div> <div class="clearfix"></div> diff --git a/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs b/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs index 12f0309d6..e7d070d81 100644 --- a/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs +++ b/app/assets/javascripts/discourse/templates/modal/avatar_selector.hbs @@ -2,32 +2,27 @@ <div> <div> <input type="radio" id="system-avatar" name="avatar" value="system" {{action "useSystem"}}> - <label class="radio" for="system-avatar">{{bound-avatar controller "large" system_avatar_upload_id}} {{{i18n 'user.change_avatar.letter_based'}}}</label> + <label class="radio" for="system-avatar">{{bound-avatar-template system_avatar_template "large"}} {{{i18n 'user.change_avatar.letter_based'}}}</label> </div> <div> <input type="radio" id="gravatar" name="avatar" value="gravatar" {{action "useGravatar"}}> - <label class="radio" for="gravatar">{{bound-avatar controller "large" gravatar_avatar_upload_id}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label> + <label class="radio" for="gravatar">{{bound-avatar-template gravatar_avatar_template "large"}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label> {{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}} </div> {{#if allowImageUpload}} <div> <input type="radio" id="uploaded_avatar" name="avatar" value="uploaded" {{action "useUploadedAvatar"}}> <label class="radio" for="uploaded_avatar"> - {{#if hasUploadedAvatar}} - {{#if uploadedAvatarTemplate}} - {{bound-avatar-template uploadedAvatarTemplate "large"}} - {{else}} - {{bound-avatar controller "large" custom_avatar_upload_id}} - {{/if}} + {{#if custom_avatar_template}} + {{bound-avatar-template custom_avatar_template "large"}} {{i18n 'user.change_avatar.uploaded_avatar'}} {{else}} {{i18n 'user.change_avatar.uploaded_avatar_empty'}} {{/if}} </label> - {{avatar-uploader username=username - user_id=id - uploadedAvatarTemplate=uploadedAvatarTemplate - custom_avatar_upload_id=custom_avatar_upload_id + {{avatar-uploader user_id=id + uploadedAvatarTemplate=custom_avatar_template + uploadedAvatarId=custom_avatar_upload_id uploading=uploading done="useUploadedAvatar"}} </div> @@ -36,6 +31,6 @@ </div> <div class="modal-footer"> - {{d-button action="saveAvatarSelection" class="btn-primary" disabled=saveDisabled label="save"}} + {{d-button action="saveAvatarSelection" class="btn-primary" disabled=uploading label="save"}} <a {{action "closeModal"}}>{{i18n 'cancel'}}</a> </div> diff --git a/app/assets/javascripts/discourse/templates/post.hbs b/app/assets/javascripts/discourse/templates/post.hbs index 29b95d5aa..ff990eb66 100644 --- a/app/assets/javascripts/discourse/templates/post.hbs +++ b/app/assets/javascripts/discourse/templates/post.hbs @@ -8,7 +8,7 @@ {{view 'reply-history' content=replyHistory}} </div> -<article {{bind-attr class=":boxed via_email" id="postElementId" data-post-id="id" data-user-id="user_id"}}> +<article class="boxed {{if via_email 'via-email'}}" id={{postElementId}} data-post-id={{id}} data-user-id={{user_id}}> <div class='row'> <div class='topic-avatar'> @@ -45,15 +45,20 @@ </div> {{/if}} {{#if wiki}} - <div class="post-info wiki" title="{{i18n 'post.wiki.about'}}" {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div> + <div class="post-info wiki" title={{i18n 'post.wiki.about'}} {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div> {{/if}} {{#if via_email}} {{#if canViewRawEmail}} - <div class="post-info via-email raw-email" title="{{i18n 'post.via_email'}}" {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div> + <div class="post-info via-email raw-email" title={{i18n 'post.via_email'}} {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div> {{else}} - <div class="post-info via-email" title="{{i18n 'post.via_email'}}">{{fa-icon "envelope-o"}}</div> + <div class="post-info via-email" title={{i18n 'post.via_email'}}>{{fa-icon "envelope-o"}}</div> {{/if}} {{/if}} + + {{#if view.whisper}} + <div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "eye-slash"}}</div> + {{/if}} + {{#if showUserReplyTab}} <a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'> {{#if loadingReplyHistory}} diff --git a/app/assets/javascripts/discourse/views/avatar-selector.js.es6 b/app/assets/javascripts/discourse/views/avatar-selector.js.es6 index 15b8541ef..6fcc5c9bc 100644 --- a/app/assets/javascripts/discourse/views/avatar-selector.js.es6 +++ b/app/assets/javascripts/discourse/views/avatar-selector.js.es6 @@ -1,3 +1,4 @@ +import { on, observes } from "ember-addons/ember-computed-decorators"; import ModalBodyView from "discourse/views/modal-body"; export default ModalBodyView.extend({ @@ -6,11 +7,14 @@ export default ModalBodyView.extend({ title: I18n.t('user.change_avatar.title'), // *HACK* used to select the proper radio button, because {{action}} stops the default behavior - selectedChanged: function() { + @on("didInsertElement") + @observes("controller.selected") + selectedChanged() { Em.run.next(() => $('input:radio[name="avatar"]').val([this.get('controller.selected')])); - }.observes('controller.selected').on("didInsertElement"), + }, - _focusSelectedButton: function() { + @on("didInsertElement") + _focusSelectedButton() { Em.run.next(() => $('input:radio[value="' + this.get('controller.selected') + '"]').focus()); - }.on("didInsertElement") + } }); diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index d96c6a55a..5d62db416 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -1,7 +1,6 @@ import userSearch from 'discourse/lib/user-search'; import afterTransition from 'discourse/lib/after-transition'; import loadScript from 'discourse/lib/load-script'; -import avatarTemplate from 'discourse/lib/avatar-template'; import positioningWorkaround from 'discourse/lib/safari-hacks'; import debounce from 'discourse/lib/debounce'; import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions'; @@ -251,10 +250,7 @@ const ComposerView = Ember.View.extend(Ember.Evented, { if (posts && topicId === self.get('controller.controllers.topic.model.id')) { const quotedPost = posts.findProperty("post_number", postNumber); if (quotedPost) { - const username = quotedPost.get('username'), - uploadId = quotedPost.get('uploaded_avatar_id'); - - return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId)); + return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template')); } } } diff --git a/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6 b/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6 new file mode 100644 index 000000000..9a8559f1a --- /dev/null +++ b/app/assets/javascripts/discourse/views/list/post-count-or-badges.js.es6 @@ -0,0 +1,4 @@ +export default Ember.Object.extend({ + postCountsPresent: Ember.computed.or('topic.unread', 'topic.displayNewPosts', 'topic.unseen'), + showBadges: Ember.computed.and('postBadgesEnabled', 'postCountsPresent') +}); diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index ef1869e90..e2c663177 100644 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -1,6 +1,8 @@ import ScreenTrack from 'discourse/lib/screen-track'; import { number } from 'discourse/lib/formatter'; import DiscourseURL from 'discourse/lib/url'; +import computed from 'ember-addons/ember-computed-decorators'; +import { fmt } from 'discourse/lib/computed'; const DAY = 60 * 50 * 1000; @@ -12,10 +14,18 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, { 'post.deleted:deleted', 'post.topicOwner:topic-owner', 'groupNameClass', - 'post.wiki:wiki'], + 'post.wiki:wiki', + 'whisper'], post: Ember.computed.alias('content'), + postElementId: fmt('post.post_number', 'post_%@'), + + @computed('post.post_type') + whisper(postType) { + return postType === this.site.get('post_types.whisper'); + }, + templateName: function() { return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post'; }.property('post.post_type'), diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 797cab865..757f7308e 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -14,7 +14,6 @@ //= require ./discourse/lib/load-script //= require ./discourse/lib/notification-levels //= require ./discourse/lib/app-events -//= require ./discourse/lib/avatar-template //= require ./discourse/lib/url //= require ./discourse/lib/debounce //= require ./discourse/lib/quote @@ -41,7 +40,6 @@ //= require ./discourse/lib/autocomplete //= require ./discourse/lib/after-transition //= require ./discourse/lib/debounce -//= require ./discourse/lib/avatar-template //= require ./discourse/lib/safari-hacks //= require_tree ./discourse/adapters //= require ./discourse/models/rest diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 3b0d62008..dab4f08f1 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -147,7 +147,7 @@ aside.quote { } .post-info { - &.wiki, &.via-email { + &.wiki, &.via-email, &.whisper { margin-right: 5px; i.fa { font-size: 1em; @@ -317,3 +317,12 @@ table.md-table { clear: both; } + +.whisper { + .topic-body { + .cooked { + font-style: italic; + color: dark-light-diff($primary, $secondary, 55%, -40%); + } + } +} diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 9fea1acef..ca821824d 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -324,8 +324,8 @@ } .title-input .popup-tip { width: 300px; - left: 0px; - top: -30px; + margin-top: 8px; + left: 150px; } .category-input .popup-tip { width: 240px; diff --git a/app/assets/stylesheets/embed.css.scss b/app/assets/stylesheets/embed.css.scss index 40eab2875..9eee09134 100644 --- a/app/assets/stylesheets/embed.css.scss +++ b/app/assets/stylesheets/embed.css.scss @@ -14,8 +14,8 @@ article.post { } .quote .title { - border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%); - background-color: dark-light-diff($primary, $secondary, 90%, -60%); + border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%); + background-color: dark-light-diff($primary, $secondary, 97%, -45%); padding: 10px 10px 0 12px; .avatar { margin-right: 7px; } } @@ -27,8 +27,8 @@ article.post { blockquote { padding: 10px 8px 10px 13px; margin: 0 0 10px 0; - background-color: dark-light-diff($primary, $secondary, 90%, -60%); - border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%); + background-color: dark-light-diff($primary, $secondary, 97%, -45%); + border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%); overflow: hidden; p { margin: 0 0 10px 0; @@ -137,9 +137,3 @@ footer { float: right; max-height: 30px; } - -.button { - background-color: #eee; - padding: 5px; - display: inline-block; -} diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss index 6922da71c..1b59b39e7 100644 --- a/app/assets/stylesheets/mobile/topic-list.scss +++ b/app/assets/stylesheets/mobile/topic-list.scss @@ -80,7 +80,17 @@ .badge-notification { position: relative; top: -1px; - i {color: $secondary;} + font-size: 1.071em; + padding: 4px 6px 3px 6px; + i {color: $secondary;} + + &.new-topic::before { + content: none; + margin-right: 0; + } + &.new-topic { + padding-right: 0; + } } .topic-item-stats { @@ -91,7 +101,7 @@ .category a { max-width: 160px; } - .num .fa { + .num .fa, a, a:visited { color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%)); } } @@ -110,6 +120,29 @@ // Category list // -------------------------------------------------- +.categories-list .list-container { + margin-left: -10px; // Extend past the .wrap padding to the edge of the window +} + +.category-list-item.category { + // Allow percentage widths on table cells to include their padding + box-sizing: border-box; + *, *:before, *:after { + box-sizing: inherit; + } + + .main-link { + width: 80%; + } + + .posts { + width: 10%; + } + + .age { + width: 10%; + } +} tr.category-topic-link { border-top: darken($secondary, 3%) 1px solid; @@ -357,7 +390,7 @@ td .main-link { padding: 5px 10px 5px 0; } } -.topic-list-item { +.topic-list { .posts-map { font-size: 1.071em; padding-top: 2px; diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a35e2f423..5ee72c315 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -465,6 +465,10 @@ class PostsController < ApplicationController result[:is_warning] = false end + if SiteSetting.enable_whispers? && params[:whisper] == "true" + result[:post_type] = Post.types[:whisper] + end + PostRevisor.tracked_topic_fields.each_key do |f| params.permit(f => []) result[f] = params[f] if params.has_key?(f) diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb index ed8f31e4a..266648107 100644 --- a/app/controllers/user_avatars_controller.rb +++ b/app/controllers/user_avatars_controller.rb @@ -13,7 +13,10 @@ class UserAvatarsController < ApplicationController user.create_user_avatar(user_id: user.id) unless user.user_avatar user.user_avatar.update_gravatar! - render json: { upload_id: user.user_avatar.gravatar_upload_id } + render json: { + gravatar_upload_id: user.user_avatar.gravatar_upload_id, + gravatar_avatar_template: User.avatar_template(user.username, user.user_avatar.gravatar_upload_id) + } else raise Discourse::NotFound end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d537c6b7a..bb361de6f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -515,13 +515,13 @@ class UsersController < ApplicationController results = UserSearch.new(term, topic_id: topic_id, topic_allowed_users: topic_allowed_users, searching_user: current_user).search - user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id] + user_fields = [:username, :upload_avatar_template] user_fields << :name if SiteSetting.enable_names? - to_render = { users: results.as_json(only: user_fields, methods: :avatar_template) } + to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template]) } if params[:include_groups] == "true" - to_render[:groups] = Group.search_group(term, current_user).map {|m| {:name=>m.name, :usernames=> m.usernames.split(",")} } + to_render[:groups] = Group.search_group(term, current_user).map { |m| { name: m.name, usernames: m.usernames.split(",") } } end render json: to_render @@ -531,13 +531,15 @@ class UsersController < ApplicationController user = fetch_user_from_params guardian.ensure_can_edit!(user) + type = params[:type] upload_id = params[:upload_id] user.uploaded_avatar_id = upload_id - # ensure we associate the custom avatar properly - if upload_id && user.user_avatar.custom_upload_id != upload_id + if type == "uploaded" || type == "custom" user.user_avatar.custom_upload_id = upload_id + elsif type == "gravatar" + user.user_avatar.gravatar_upload_id = upload_id end user.save! diff --git a/app/models/post.rb b/app/models/post.rb index 3a6a8d515..3cf1fb367 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -74,7 +74,7 @@ class Post < ActiveRecord::Base end def self.types - @types ||= Enum.new(:regular, :moderator_action, :small_action) + @types ||= Enum.new(:regular, :moderator_action, :small_action, :whisper) end def self.cook_methods @@ -96,15 +96,24 @@ class Post < ActiveRecord::Base end def publish_change_to_clients!(type) - # special failsafe for posts missing topics - # consistency checks should fix, but message + + channel = "/topic/#{topic_id}" + msg = { id: id, + post_number: post_number, + updated_at: Time.now, + type: type } + + # special failsafe for posts missing topics consistency checks should fix, but message # is safe to skip - MessageBus.publish("/topic/#{topic_id}", { - id: id, - post_number: post_number, - updated_at: Time.now, - type: type - }, group_ids: topic.secure_group_ids) if topic + return unless topic + + # Whispers should not be published to everyone + if post_type == Post.types[:whisper] + user_ids = User.where('admin or moderator or id = ?', user_id).pluck(:id) + MessageBus.publish(channel, msg, user_ids: user_ids) + else + MessageBus.publish(channel, msg, group_ids: topic.secure_group_ids) + end end def trash!(trashed_by=nil) diff --git a/app/models/topic.rb b/app/models/topic.rb index 442fb43c2..703b54726 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -218,6 +218,13 @@ class Topic < ActiveRecord::Base end end + def visible_post_types(viewed_by=nil) + types = Post.types + result = [types[:regular], types[:moderator_action], types[:small_action]] + result << types[:whisper] if viewed_by.try(:staff?) + result + end + def self.top_viewed(max = 10) Topic.listable_topics.visible.secured.order('views desc').limit(max) end diff --git a/app/models/upload.rb b/app/models/upload.rb index 39e5fb94e..bcc468d44 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -50,7 +50,7 @@ class Upload < ActiveRecord::Base end # list of image types that will be cropped - CROPPED_IMAGE_TYPES ||= ["avatar", "profile_background", "card_background"] + CROPPED_IMAGE_TYPES ||= %w{avatar profile_background card_background} # options # - content_type diff --git a/app/models/user.rb b/app/models/user.rb index 2861fff88..cd4792ea8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -454,26 +454,41 @@ class User < ActiveRecord::Base [((result << 5) - result) + char.ord].pack('L').unpack('l').first end - avatar_template = split_avatars[hash.abs % split_avatars.size] + split_avatars[hash.abs % split_avatars.size] end + else + system_avatar_template(username) + end + end + + def self.avatar_template(username, uploaded_avatar_id) + username ||= "" + return default_template(username) if !uploaded_avatar_id + hostname = RailsMultisite::ConnectionManagement.current_hostname + UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id) + end + + def self.system_avatar_template(username) + # TODO it may be worth caching this in a distributed cache, should be benched + if SiteSetting.external_system_avatars_enabled + url = SiteSetting.external_system_avatars_url.dup + url.gsub! "{color}", letter_avatar_color(username.downcase) + url.gsub! "{username}", username + url.gsub! "{first_letter}", username[0].downcase + url else "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png" end end - def self.avatar_template(username,uploaded_avatar_id) - return default_template(username) if !uploaded_avatar_id + def self.letter_avatar_color(username) username ||= "" - hostname = RailsMultisite::ConnectionManagement.current_hostname - UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id) - end - - def self.letter_avatar_template(username) - "#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png" + color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length] + color.map { |c| c.to_s(16).rjust(2, '0') }.join end def avatar_template - self.class.avatar_template(username,uploaded_avatar_id) + self.class.avatar_template(username, uploaded_avatar_id) end # The following count methods are somewhat slow - definitely don't use them in a loop. diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb index f61df736b..8d57472c6 100644 --- a/app/models/user_avatar.rb +++ b/app/models/user_avatar.rb @@ -39,8 +39,7 @@ class UserAvatar < ActiveRecord::Base end def self.local_avatar_url(hostname, username, upload_id, size) - version = self.version(upload_id) - "#{Discourse.base_uri}/user_avatar/#{hostname}/#{username}/#{size}/#{version}.png" + self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size.to_s) end def self.local_avatar_template(hostname, username, upload_id) @@ -49,8 +48,7 @@ class UserAvatar < ActiveRecord::Base end def self.external_avatar_url(user_id, upload_id, size) - version = self.version(upload_id) - "#{Discourse.store.absolute_base_url}/avatars/#{user_id}/#{size}/#{version}.png" + self.external_avatar_template(user_id, upload_id).gsub("{size}", size.to_s) end def self.external_avatar_template(user_id, upload_id) diff --git a/app/serializers/admin_post_serializer.rb b/app/serializers/admin_post_serializer.rb index 2dd29df5c..a06328d83 100644 --- a/app/serializers/admin_post_serializer.rb +++ b/app/serializers/admin_post_serializer.rb @@ -3,7 +3,7 @@ class AdminPostSerializer < ApplicationSerializer attributes :id, :created_at, :post_number, - :name, :username, :avatar_template, :uploaded_avatar_id, + :name, :username, :avatar_template, :topic_id, :topic_slug, :topic_title, :category_id, :excerpt, @@ -29,10 +29,6 @@ class AdminPostSerializer < ApplicationSerializer object.user.avatar_template end - def uploaded_avatar_id - object.user.uploaded_avatar_id - end - def topic_slug topic.slug end diff --git a/app/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb index 04b91ecad..8969d19a0 100644 --- a/app/serializers/basic_post_serializer.rb +++ b/app/serializers/basic_post_serializer.rb @@ -4,7 +4,6 @@ class BasicPostSerializer < ApplicationSerializer :name, :username, :avatar_template, - :uploaded_avatar_id, :created_at, :cooked, :cooked_hidden @@ -21,13 +20,10 @@ class BasicPostSerializer < ApplicationSerializer object.user.try(:avatar_template) end - def uploaded_avatar_id - object.user.try(:uploaded_avatar_id) - end - def cooked_hidden object.hidden && !scope.is_staff? end + def include_cooked_hidden? cooked_hidden end diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb index 8911291f3..8880c8dbd 100644 --- a/app/serializers/basic_user_serializer.rb +++ b/app/serializers/basic_user_serializer.rb @@ -1,5 +1,5 @@ class BasicUserSerializer < ApplicationSerializer - attributes :id, :username, :uploaded_avatar_id, :avatar_template + attributes :id, :username, :avatar_template def include_name? SiteSetting.enable_names? @@ -9,7 +9,7 @@ class BasicUserSerializer < ApplicationSerializer if Hash === object User.avatar_template(user[:username], user[:uploaded_avatar_id]) else - object.avatar_template + user.try(:avatar_template) end end diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index 1bf08f89f..6138f1d1b 100644 --- a/app/serializers/category_serializer.rb +++ b/app/serializers/category_serializer.rb @@ -11,6 +11,7 @@ class CategorySerializer < BasicCategorySerializer :suppress_from_homepage, :can_delete, :cannot_delete_reason, + :is_special, :allow_badges, :custom_fields @@ -37,6 +38,15 @@ class CategorySerializer < BasicCategorySerializer true end + def include_is_special? + [SiteSetting.lounge_category_id, SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id] + .include? object.id + end + + def is_special + true + end + def include_can_delete? scope && scope.can_delete?(object) end diff --git a/app/serializers/post_action_user_serializer.rb b/app/serializers/post_action_user_serializer.rb index b69a27348..72dbd8e4d 100644 --- a/app/serializers/post_action_user_serializer.rb +++ b/app/serializers/post_action_user_serializer.rb @@ -9,10 +9,6 @@ class PostActionUserSerializer < BasicUserSerializer object.user.username end - def uploaded_avatar_id - object.user.uploaded_avatar_id - end - def avatar_template object.user.avatar_template end diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 5ba620ca2..a10f9dbf6 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -177,8 +177,7 @@ class PostSerializer < BasicPostSerializer def reply_to_user { username: object.reply_to_user.username, - avatar_template: object.reply_to_user.avatar_template, - uploaded_avatar_id: object.reply_to_user.uploaded_avatar_id + avatar_template: object.reply_to_user.avatar_template } end diff --git a/app/serializers/topic_post_count_serializer.rb b/app/serializers/topic_post_count_serializer.rb index 586f9f2d7..c780d1203 100644 --- a/app/serializers/topic_post_count_serializer.rb +++ b/app/serializers/topic_post_count_serializer.rb @@ -14,8 +14,4 @@ class TopicPostCountSerializer < BasicUserSerializer object[:post_count] end - def uploaded_avatar_id - object[:user].uploaded_avatar_id - end - end diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb index 8b3939963..d9609ff9b 100644 --- a/app/serializers/user_action_serializer.rb +++ b/app/serializers/user_action_serializer.rb @@ -26,10 +26,8 @@ class UserActionSerializer < ApplicationSerializer :action_code, :edit_reason, :category_id, - :uploaded_avatar_id, :closed, - :archived, - :acting_uploaded_avatar_id + :archived def excerpt cooked = object.cooked || PrettyText.cook(object.raw) diff --git a/app/serializers/user_name_serializer.rb b/app/serializers/user_name_serializer.rb index ac7beaa8d..3d7fc0d1f 100644 --- a/app/serializers/user_name_serializer.rb +++ b/app/serializers/user_name_serializer.rb @@ -1,20 +1,3 @@ -class UserNameSerializer < ApplicationSerializer - attributes :id, :username, :name, :title, :uploaded_avatar_id, :avatar_template - - def include_name? - SiteSetting.enable_names? - end - - def avatar_template - if Hash === object - User.avatar_template(user[:username], user[:uploaded_avatar_id]) - else - object.avatar_template - end - end - - def user - object[:user] || object - end - +class UserNameSerializer < BasicUserSerializer + attributes :name, :title end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 895453990..334b20e2d 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -93,8 +93,12 @@ class UserSerializer < BasicUserSerializer :watched_category_ids, :private_messages_stats, :disable_jump_reply, + :system_avatar_upload_id, + :system_avatar_template, :gravatar_avatar_upload_id, + :gravatar_avatar_template, :custom_avatar_upload_id, + :custom_avatar_template, :has_title_badges, :card_image_badge, :card_image_badge_id, @@ -278,14 +282,32 @@ class UserSerializer < BasicUserSerializer UserAction.private_messages_stats(object.id, scope) end + def system_avatar_upload_id + # should be left blank + end + + def system_avatar_template + User.system_avatar_template(object.username) + end + def gravatar_avatar_upload_id object.user_avatar.try(:gravatar_upload_id) end + def gravatar_avatar_template + return unless gravatar_upload_id = object.user_avatar.try(:gravatar_upload_id) + User.avatar_template(object.username, gravatar_upload_id) + end + def custom_avatar_upload_id object.user_avatar.try(:custom_upload_id) end + def custom_avatar_template + return unless custom_upload_id = object.user_avatar.try(:custom_upload_id) + User.avatar_template(object.username, custom_upload_id) + end + def has_title_badges object.badges.where(allow_title: true).count > 0 end @@ -323,4 +345,5 @@ class UserSerializer < BasicUserSerializer def pending_count 0 end + end diff --git a/app/views/list/list.erb b/app/views/list/list.erb index a6768190d..b9ae8a086 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -22,7 +22,7 @@ <span itemprop='name'><%= t.title %></span> </a> <%= page_links(t) %> - <% if !@category && t.category %> + <% if (!@category || @category.has_children?) && t.category %> <span>[<a href='<%= t.category.url %>'><%= t.category.name %></a>]</span> <% end %> <span title='<%= t 'posts' %>'>(<a href="<%=t.last_post_url%>"><%= t.posts_count %></a>)</span> diff --git a/config/initializers/i18n.rb b/config/initializers/i18n.rb index 00dab6775..43b729957 100644 --- a/config/initializers/i18n.rb +++ b/config/initializers/i18n.rb @@ -28,8 +28,10 @@ class NoFallbackLocaleList < FallbackLocaleList end end -if Rails.env.production? - I18n.fallbacks = FallbackLocaleList.new -else + +if Rails.env.development? I18n.fallbacks = NoFallbackLocaleList.new +else + I18n.fallbacks = FallbackLocaleList.new + I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) } end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 8bf64d56c..866e6f7fb 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -813,6 +813,7 @@ en: emoji: "Emoji :smile:" add_warning: "This is an official warning." + add_whisper: "This is a whisper only visible to moderators" posting_not_on_topic: "Which topic do you want to reply to?" saving_draft_tip: "saving..." saved_draft_tip: "saved" @@ -1353,6 +1354,7 @@ en: yes_value: "Yes, abandon" via_email: "this post arrived via email" + whisper: "this post is a private whisper for moderators" wiki: about: "this post is a wiki; basic users can edit it" @@ -1544,6 +1546,7 @@ en: change_in_category_topic: "Edit Description" already_used: 'This color has been used by another category' security: "Security" + special_warning: "Warning: This category is a pre-seeded category and the security settings cannot be edited. If you do not wish to use this category, delete it instead of repurposing it." images: "Images" auto_close_label: "Auto-close topics after:" auto_close_units: "hours" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fcd190dc8..6f765f159 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -94,7 +94,7 @@ en: min_username_length_exists: "You cannot set the minimum username length above the shortest username." min_username_length_range: "You cannot set the minimum above the maximum." max_username_length_exists: "You cannot set the maximum username length below the longest username." - max_username_length_range: "You cannot set the maximum above the minimum." + max_username_length_range: "You cannot set the maximum below the minimum." default_categories_already_selected: "You cannot select a category used in another list." activemodel: @@ -880,6 +880,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 users to whisper to moderators" 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" @@ -979,6 +980,9 @@ en: avatar_sizes: "List of automatically generated avatar sizes." + external_system_avatars_enabled: "Use external system avatars service." + external_system_avatars_url: "URL of the external system avatars service. Allowed substitions are {username} {first_letter} {color} {size}" + enable_flash_video_onebox: "Enable embedding of swf and flv (Adobe Flash) links in oneboxes. WARNING: may introduce security risks." default_invitee_trust_level: "Default trust level (0-4) for invited users." @@ -1199,8 +1203,8 @@ en: default_email_mailing_list_mode: "Send an email for every new post by default." default_email_always: "Send an email notification even when the user is active by default." - default_other_new_topic_duration_minutes: "Global default number of minutes a topic is considered new." - default_other_auto_track_topics_after_msecs: "Global default milliseconds before a topic is automatically tracked." + default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new." + default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked." default_other_external_links_in_new_tab: "Open external links in a new tab by default." default_other_enable_quoting: "Enable quote reply for highlighted text by default." default_other_dynamic_favicon: "Show new/updated topic count on browser icon by default." @@ -1344,7 +1348,7 @@ en: unique: "must be unique" blank: "must be present" must_begin_with_alphanumeric: "must begin with a letter or number or an underscore" - must_end_with_alphanumeric: "must end with a letter or number" + must_end_with_alphanumeric: "must end with a letter or number or an underscore" must_not_contain_two_special_chars_in_seq: "must not contain a sequence of 2 or more special chars (.-_)" must_not_contain_confusing_suffix: "must not contain a confusing suffix like .json or .png etc." email: diff --git a/config/site_settings.yml b/config/site_settings.yml index 754e067b8..1c8e9f112 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -182,6 +182,9 @@ basic: enable_badges: client: true default: true + enable_whispers: + client: true + default: false login: invite_only: @@ -418,9 +421,6 @@ posting: newuser_max_attachments: client: true default: 0 - uncategorized_category_id: - default: -1 - hidden: true post_excerpt_maxlength: 300 display_name_on_posts: client: true @@ -572,6 +572,14 @@ files: avatar_sizes: default: '20|25|32|45|60|120' type: list + external_system_avatars_enabled: + default: false + client: true + shadowed_by_global: true + external_system_avatars_url: + default: "https://avatars.discourse.org/letter/{first_letter}/{color}/{size}.png" + client: true + regex: '^https?:\/\/.+[^\/]' trust: default_trust_level: @@ -922,14 +930,15 @@ uncategorized: lounge_category_id: default: -1 hidden: true - meta_category_id: default: -1 hidden: true - staff_category_id: default: -1 hidden: true + uncategorized_category_id: + default: -1 + hidden: true performance_report_topic_id: default: -1 diff --git a/lib/avatar_lookup.rb b/lib/avatar_lookup.rb index b1fc1c616..4af184052 100644 --- a/lib/avatar_lookup.rb +++ b/lib/avatar_lookup.rb @@ -12,10 +12,7 @@ class AvatarLookup private def self.lookup_columns - @lookup_columns ||= [:id, - :email, - :username, - :uploaded_avatar_id] + @lookup_columns ||= %i{id email username uploaded_avatar_id} end def users diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index e3f1a030b..5b03eff43 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -144,10 +144,13 @@ module PostGuardian end def can_see_post?(post) - post.present? && - (is_admin? || - ((is_moderator? || !post.deleted_at.present?) && - can_see_topic?(post.topic))) + return false if post.blank? + return true if is_admin? + return false unless can_see_topic?(post.topic) + return false unless post.user == @user || post.topic.visible_post_types(@user).include?(post.post_type) + return false if !is_moderator? && post.deleted_at.present? + + true end def can_view_edit_history?(post) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 54483efd5..52222a6d3 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -191,11 +191,9 @@ class TopicView # Find the sort order for a post in the topic def sort_order_for_post_number(post_number) - Post.where(topic_id: @topic.id, post_number: post_number) - .with_deleted - .select(:sort_order) - .first - .try(:sort_order) + posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted + posts = filter_post_types(posts) + posts.select(:sort_order).first.try(:sort_order) end # Filter to all posts near a particular post number @@ -332,11 +330,22 @@ class TopicView private + def filter_post_types(posts) + visible_types = @topic.visible_post_types(@user) + + if @user.present? + posts.where("user_id = ? OR post_type IN (?)", @user.id, visible_types) + else + posts.where(post_type: visible_types) + end + end + def filter_posts_by_ids(post_ids) # TODO: Sort might be off @posts = Post.where(id: post_ids, topic_id: @topic.id) .includes(:user, :reply_to_user) .order('sort_order') + @posts = filter_post_types(@posts) @posts = @posts.with_deleted if @guardian.can_see_deleted_posts? @posts end @@ -355,13 +364,13 @@ class TopicView end def find_topic(topic_id) - finder = Topic.where(id: topic_id).includes(:category) - finder = finder.with_deleted if @guardian.can_see_deleted_topics? + # with_deleted covered in #check_and_raise_exceptions + finder = Topic.with_deleted.where(id: topic_id).includes(:category) finder.first end def unfiltered_posts - result = @topic.posts + result = filter_post_types(@topic.posts) result = result.with_deleted if @guardian.can_see_deleted_posts? result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users result diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb index 09e4db698..4819a3f87 100644 --- a/lib/user_name_suggester.rb +++ b/lib/user_name_suggester.rb @@ -38,6 +38,7 @@ module UserNameSuggester name = name.gsub(/^[^[:alnum:]]+|\W+$/, "") .gsub(/\W+/, "_") .gsub(/^\_+/, '') + .gsub(/[\-_\.]{2,}/, "_") name end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 872766649..66430c8f8 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -437,6 +437,32 @@ describe Guardian do expect(Guardian.new(user).can_see?(post)).to be_falsey expect(Guardian.new(admin).can_see?(post)).to be_truthy end + + it 'respects whispers' do + regular_post = Fabricate.build(:post) + whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper]) + + anon_guardian = Guardian.new + expect(anon_guardian.can_see?(regular_post)).to eq(true) + expect(anon_guardian.can_see?(whisper_post)).to eq(false) + + regular_user = Fabricate.build(:user) + regular_guardian = Guardian.new(regular_user) + expect(regular_guardian.can_see?(regular_post)).to eq(true) + expect(regular_guardian.can_see?(whisper_post)).to eq(false) + + # can see your own whispers + regular_whisper = Fabricate.build(:post, post_type: Post.types[:whisper], user: regular_user) + expect(regular_guardian.can_see?(regular_whisper)).to eq(true) + + mod_guardian = Guardian.new(Fabricate.build(:moderator)) + expect(mod_guardian.can_see?(regular_post)).to eq(true) + expect(mod_guardian.can_see?(whisper_post)).to eq(true) + + admin_guardian = Guardian.new(Fabricate.build(:admin)) + expect(admin_guardian.can_see?(regular_post)).to eq(true) + expect(admin_guardian.can_see?(whisper_post)).to eq(true) + end end describe 'a PostRevision' do diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index dedc080b9..ad23e0ade 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -13,6 +13,7 @@ describe TopicView do expect { TopicView.new(1231232, coding_horror) }.to raise_error(Discourse::NotFound) end + # see also spec/controllers/topics_controller_spec.rb TopicsController::show::permission errors it "raises an error if the user can't see the topic" do Guardian.any_instance.expects(:can_see?).with(topic).returns(false) expect { topic_view }.to raise_error(Discourse::InvalidAccess) @@ -21,7 +22,7 @@ describe TopicView do it "handles deleted topics" do admin = Fabricate(:admin) topic.trash!(admin) - expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::NotFound) + expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::InvalidAccess) expect { TopicView.new(topic.id, admin) }.not_to raise_error end @@ -251,6 +252,23 @@ describe TopicView do end + context 'whispers' do + it "handles their visibility properly" do + p1 = Fabricate(:post, topic: topic, user: coding_horror) + p2 = Fabricate(:post, topic: topic, user: coding_horror, post_type: Post.types[:whisper]) + p3 = Fabricate(:post, topic: topic, user: coding_horror) + + ch_posts = TopicView.new(topic.id, coding_horror).posts + expect(ch_posts.map(&:id)).to eq([p1.id, p2.id, p3.id]) + + anon_posts = TopicView.new(topic.id).posts + expect(anon_posts.map(&:id)).to eq([p1.id, p3.id]) + + admin_posts = TopicView.new(topic.id, Fabricate(:moderator)).posts + expect(admin_posts.map(&:id)).to eq([p1.id, p2.id, p3.id]) + end + end + context '.posts' do # Create the posts in a different order than the sort_order diff --git a/spec/components/user_name_suggester_spec.rb b/spec/components/user_name_suggester_spec.rb index 8f63ba1b2..ec9eb7a46 100644 --- a/spec/components/user_name_suggester_spec.rb +++ b/spec/components/user_name_suggester_spec.rb @@ -75,6 +75,11 @@ describe UserNameSuggester do expect(UserNameSuggester.suggest("myname.")).to eq('myname') end + it 'handles usernames with a sequence of 2 or more special chars' do + expect(UserNameSuggester.suggest('Darth__Vader')).to eq('Darth_Vader') + expect(UserNameSuggester.suggest('Darth_-_Vader')).to eq('Darth_Vader') + end + it 'should handle typical facebook usernames' do expect(UserNameSuggester.suggest('roger.nelson.3344913')).to eq('roger_nelson_33') end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index fd927ebac..e87a27f48 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -1,5 +1,23 @@ require 'spec_helper' +def topics_controller_show_gen_perm_tests(expected, ctx) + expected.each do |sym, status| + params = "topic_id: #{sym}.id, slug: #{sym}.slug" + if sym == :nonexist + params = "topic_id: nonexist_topic_id" + end + ctx.instance_eval(" +it 'returns #{status} for #{sym}' do + begin + xhr :get, :show, #{params} + expect(response.status).to eq(#{status}) + rescue Discourse::NotLoggedIn + expect(302).to eq(#{status}) + end +end") + end +end + describe TopicsController do context 'wordpress' do @@ -554,6 +572,108 @@ describe TopicsController do end end + context 'permission errors' do + let(:allowed_user) { Fabricate(:user) } + let(:allowed_group) { Fabricate(:group) } + let(:secure_category) { + c = Fabricate(:category) + c.permissions = [[allowed_group, :full]] + c.save + allowed_user.groups = [allowed_group] + allowed_user.save + c } + let(:normal_topic) { Fabricate(:topic) } + let(:secure_topic) { Fabricate(:topic, category: secure_category) } + let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) } + let(:deleted_topic) { Fabricate(:deleted_topic) } + let(:nonexist_topic_id) { Topic.last.id + 10000 } + + context 'anonymous' do + expected = { + :normal_topic => 200, + :secure_topic => 403, + :private_topic => 302, + :deleted_topic => 403, + :nonexist => 404 + } + topics_controller_show_gen_perm_tests(expected, self) + end + + context 'anonymous with login required' do + before do + SiteSetting.login_required = true + end + expected = { + :normal_topic => 302, + :secure_topic => 302, + :private_topic => 302, + :deleted_topic => 302, + :nonexist => 302 + } + topics_controller_show_gen_perm_tests(expected, self) + end + + context 'normal user' do + before do + log_in(:user) + end + + expected = { + :normal_topic => 200, + :secure_topic => 403, + :private_topic => 403, + :deleted_topic => 403, + :nonexist => 404 + } + topics_controller_show_gen_perm_tests(expected, self) + end + + context 'allowed user' do + before do + log_in_user(allowed_user) + end + + expected = { + :normal_topic => 200, + :secure_topic => 200, + :private_topic => 200, + :deleted_topic => 403, + :nonexist => 404 + } + topics_controller_show_gen_perm_tests(expected, self) + end + + context 'moderator' do + before do + log_in(:moderator) + end + + expected = { + :normal_topic => 200, + :secure_topic => 403, + :private_topic => 403, + :deleted_topic => 200, + :nonexist => 404 + } + topics_controller_show_gen_perm_tests(expected, self) + end + + context 'admin' do + before do + log_in(:admin) + end + + expected = { + :normal_topic => 200, + :secure_topic => 200, + :private_topic => 200, + :deleted_topic => 200, + :nonexist => 404 + } + topics_controller_show_gen_perm_tests(expected, self) + end + end + it 'records a view' do expect { xhr :get, :show, topic_id: topic.id, slug: topic.slug }.to change(TopicViewItem, :count).by(1) end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 32544497d..884f3f3ff 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -1301,7 +1301,7 @@ describe UsersController do describe '.pick_avatar' do it 'raises an error when not logged in' do - expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1}.to raise_error(Discourse::NotLoggedIn) + expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1, type: "custom"}.to raise_error(Discourse::NotLoggedIn) end context 'while logged in' do @@ -1310,12 +1310,12 @@ describe UsersController do it 'raises an error when you don\'t have permission to toggle the avatar' do another_user = Fabricate(:user) - xhr :put, :pick_avatar, username: another_user.username, upload_id: 1 + xhr :put, :pick_avatar, username: another_user.username, upload_id: 1, type: "custom" expect(response).to be_forbidden end it 'it successful' do - xhr :put, :pick_avatar, username: user.username, upload_id: 111 + xhr :put, :pick_avatar, username: user.username, upload_id: 111, type: "custom" expect(user.reload.uploaded_avatar_id).to eq(111) expect(user.user_avatar.reload.custom_upload_id).to eq(111) expect(response).to be_success @@ -1326,13 +1326,6 @@ describe UsersController do expect(response).to be_success end - it 'returns success' do - xhr :put, :pick_avatar, username: user.username, upload_id: 111 - expect(user.reload.uploaded_avatar_id).to eq(111) - expect(response).to be_success - json = ::JSON.parse(response.body) - expect(json['success']).to eq("OK") - end end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 7471c5cd7..b65ec208c 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -11,6 +11,40 @@ describe Topic do it { is_expected.to rate_limit } + context '#visible_post_types' do + let(:types) { Post.types } + + it "returns the appropriate types for anonymous users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to_not include(types[:whisper]) + end + + it "returns the appropriate types for regular users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types(Fabricate.build(:user)) + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to_not include(types[:whisper]) + end + + it "returns the appropriate types for staff users" do + topic = Fabricate.build(:topic) + post_types = topic.visible_post_types(Fabricate.build(:moderator)) + + expect(post_types).to include(types[:regular]) + expect(post_types).to include(types[:moderator_action]) + expect(post_types).to include(types[:small_action]) + expect(post_types).to include(types[:whisper]) + end + end + context 'slug' do let(:title) { "hello world topic" } let(:slug) { "hello-world-topic" } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 63b3b45b0..701c0de01 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -144,7 +144,6 @@ Spork.prefork do FileUtils.cp("#{Rails.root}/spec/fixtures/images/#{filename}", "#{Rails.root}/tmp/spec/#{filename}") File.new("#{Rails.root}/tmp/spec/#{filename}") end - end Spork.each_run do diff --git a/test/javascripts/lib/avatar-template-test.js.es6 b/test/javascripts/lib/avatar-template-test.js.es6 deleted file mode 100644 index c2f004c8d..000000000 --- a/test/javascripts/lib/avatar-template-test.js.es6 +++ /dev/null @@ -1,16 +0,0 @@ -import avatarTemplate from 'discourse/lib/avatar-template'; - -module('lib:avatar-template'); - -test("avatarTemplate", function(){ - var oldCDN = Discourse.CDN; - var oldBase = Discourse.BaseUrl; - Discourse.BaseUrl = "frogs.com"; - - equal(avatarTemplate("sam", 1), "/user_avatar/frogs.com/sam/{size}/1.png"); - Discourse.CDN = "http://awesome.cdn.com"; - equal(avatarTemplate("sam", 1), "http://awesome.cdn.com/user_avatar/frogs.com/sam/{size}/1.png"); - Discourse.CDN = oldCDN; - Discourse.BaseUrl = oldBase; -}); -