diff --git a/.travis.yml b/.travis.yml index 952ad7a446..cf7aa1b1be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,11 +14,13 @@ matrix: env: "RAILS_MASTER=1" - rvm: 2.1 env: "RAILS_MASTER=1" + - rvm: rbx-2 fast_finish: true rvm: - 2.0.0 - 2.1 + - rbx-2 services: - redis-server diff --git a/Gemfile.lock b/Gemfile.lock index d2d90ac99f..a6dc871e50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,31 +11,31 @@ GEM execjs (~> 2.0) 6to5-source (3.3.7) CFPropertyList (2.2.8) - actionmailer (4.1.8) - actionpack (= 4.1.8) - actionview (= 4.1.8) + actionmailer (4.1.9) + actionpack (= 4.1.9) + actionview (= 4.1.9) mail (~> 2.5, >= 2.5.4) - actionpack (4.1.8) - actionview (= 4.1.8) - activesupport (= 4.1.8) + actionpack (4.1.9) + actionview (= 4.1.9) + activesupport (= 4.1.9) rack (~> 1.5.2) rack-test (~> 0.6.2) actionpack-action_caching (1.1.1) actionpack (>= 4.0.0, < 5.0) - actionview (4.1.8) - activesupport (= 4.1.8) + actionview (4.1.9) + activesupport (= 4.1.9) builder (~> 3.1) erubis (~> 2.7.0) active_model_serializers (0.8.2) activemodel (>= 3.0) - activemodel (4.1.8) - activesupport (= 4.1.8) + activemodel (4.1.9) + activesupport (= 4.1.9) builder (~> 3.1) - activerecord (4.1.8) - activemodel (= 4.1.8) - activesupport (= 4.1.8) + activerecord (4.1.9) + activemodel (= 4.1.9) + activesupport (= 4.1.9) arel (~> 5.0.0) - activesupport (4.1.8) + activesupport (4.1.9) i18n (~> 0.6, >= 0.6.9) json (~> 1.7, >= 1.7.7) minitest (~> 5.1) @@ -66,9 +66,7 @@ GEM debug_inspector (0.0.2) diff-lcs (1.2.5) docile (1.1.5) - dotenv (0.11.1) - dotenv-deployment (~> 0.0.2) - dotenv-deployment (0.0.2) + dotenv (1.0.2) email_reply_parser (0.5.8) ember-data-source (0.14) ember-source @@ -171,8 +169,8 @@ GEM fog-xml (0.1.1) fog-core nokogiri (~> 1.5, >= 1.5.11) - foreman (0.75.0) - dotenv (~> 0.11.1) + foreman (0.77.0) + dotenv (~> 1.0.2) thor (~> 0.19.1) formatador (0.2.5) fspath (2.0.6) @@ -181,13 +179,13 @@ GEM sorcerer (>= 0.3.7) guess_html_encoding (0.0.9) handlebars-source (2.0.0) - hashie (3.3.2) + hashie (3.4.0) highline (1.6.21) hike (1.2.3) hiredis (0.6.0) hitimes (1.2.2) htmlentities (4.3.3) - i18n (0.6.11) + i18n (0.7.0) image_optim (0.9.1) exifr (~> 1.1.3) fspath (~> 2.0.5) @@ -209,8 +207,8 @@ GEM thor (~> 0.15) libv8 (3.16.14.7) listen (0.7.3) - logster (0.1.6) - lru_redux (0.8.1) + logster (0.1.7) + lru_redux (0.8.4) mail (2.5.4) mime-types (~> 1.16) treetop (~> 1.4.8) @@ -274,7 +272,7 @@ GEM omniauth-twitter (1.0.1) multi_json (~> 1.3) omniauth-oauth (~> 1.0) - onebox (1.5.12) + onebox (1.5.13) moneta (~> 0.7) multi_json (~> 1.7) mustache (~> 0.99) @@ -298,30 +296,30 @@ GEM qunit-rails (0.0.7) railties rack (1.5.2) - rack-mini-profiler (0.9.2) + rack-mini-profiler (0.9.3) rack (>= 1.1.3) rack-openid (1.3.1) rack (>= 1.1.0) ruby-openid (>= 2.1.8) rack-protection (1.5.3) rack - rack-test (0.6.2) + rack-test (0.6.3) rack (>= 1.0) - rails (4.1.8) - actionmailer (= 4.1.8) - actionpack (= 4.1.8) - actionview (= 4.1.8) - activemodel (= 4.1.8) - activerecord (= 4.1.8) - activesupport (= 4.1.8) + rails (4.1.9) + actionmailer (= 4.1.9) + actionpack (= 4.1.9) + actionview (= 4.1.9) + activemodel (= 4.1.9) + activerecord (= 4.1.9) + activesupport (= 4.1.9) bundler (>= 1.3.0, < 2.0) - railties (= 4.1.8) + railties (= 4.1.9) sprockets-rails (~> 2.0) rails-observers (0.1.2) activemodel (~> 4.0) - railties (4.1.8) - actionpack (= 4.1.8) - activesupport (= 4.1.8) + railties (4.1.9) + actionpack (= 4.1.9) + activesupport (= 4.1.9) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) raindrops (0.13.0) @@ -391,7 +389,7 @@ GEM shoulda-context (1.2.1) shoulda-matchers (2.7.0) activesupport (>= 3.0.0) - sidekiq (3.3.1) + sidekiq (3.3.2) celluloid (>= 0.16.0) connection_pool (>= 2.1.1) json @@ -434,7 +432,7 @@ GEM thor (0.19.1) thread_safe (0.3.4) tilt (1.4.1) - timecop (0.7.1) + timecop (0.7.3) timers (4.0.1) hitimes treetop (1.4.15) diff --git a/app/assets/javascripts/admin/components/admin-report-counts.js.es6 b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 new file mode 100644 index 0000000000..28e45b10ec --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + tagName: 'tbody' +}); diff --git a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 index 5bb53c4b81..5e6bb325fd 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 @@ -13,6 +13,42 @@ export default Ember.ArrayController.extend({ sortProperties: ['granted_at'], sortAscending: false, + groupedBadges: function(){ + const badges = this.get('model'); + + var grouped = _.groupBy(badges, badge => badge.badge_id); + + var expanded = []; + const expandedBadges = badges.get('expandedBadges'); + + _(grouped).each(function(badges){ + var lastGranted = badges[0].granted_at; + + _.each(badges, function(badge) { + lastGranted = lastGranted < badge.granted_at ? badge.granted_at : lastGranted; + }); + + if(badges.length===1 || _.include(expandedBadges, badges[0].badge.id)){ + _.each(badges, badge => expanded.push(badge)); + return; + } + + var result = { + badge: badges[0].badge, + granted_at: lastGranted, + badges: badges, + count: badges.length, + grouped: true + }; + + expanded.push(result); + }); + + return _(expanded).sortBy(group => group.granted_at).reverse().value(); + + + }.property('model', 'model.@each', 'model.expandedBadges.@each'), + /** Array of badges that have not been granted to this user. @@ -45,6 +81,12 @@ export default Ember.ArrayController.extend({ actions: { + expandGroup: function(userBadge){ + const model = this.get('model'); + model.set('expandedBadges', model.get('expandedBadges') || []); + model.get('expandedBadges').pushObject(userBadge.badge.id); + }, + /** Grant the selected badge to the user. @@ -53,7 +95,8 @@ export default Ember.ArrayController.extend({ **/ grantBadge: function(badgeId) { var self = this; - Discourse.UserBadge.grant(badgeId, this.get('user.username')).then(function(userBadge) { + Discourse.UserBadge.grant(badgeId, this.get('user.username'), this.get('badgeReason')).then(function(userBadge) { + self.set('badgeReason', ''); self.pushObject(userBadge); Ember.run.next(function() { // Update the selected badge ID after the combobox has re-rendered. diff --git a/app/assets/javascripts/admin/controllers/change-site-customization-details.js.es6 b/app/assets/javascripts/admin/controllers/change-site-customization-details.js.es6 index 13e6086f76..1ef3e217d5 100644 --- a/app/assets/javascripts/admin/controllers/change-site-customization-details.js.es6 +++ b/app/assets/javascripts/admin/controllers/change-site-customization-details.js.es6 @@ -1,5 +1,4 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; - import ObjectController from 'discourse/controllers/object'; export default ObjectController.extend(ModalFunctionality, { @@ -7,14 +6,16 @@ export default ObjectController.extend(ModalFunctionality, { newSelected: Ember.computed.equal('selectedTab', 'new'), onShow: function() { - this.selectNew(); + this.send("selectNew"); }, - selectNew: function() { - this.set('selectedTab', 'new'); - }, + actions: { + selectNew: function() { + this.set('selectedTab', 'new'); + }, - selectPrevious: function() { - this.set('selectedTab', 'previous'); + selectPrevious: function() { + this.set('selectedTab', 'previous'); + } } }); diff --git a/app/assets/javascripts/admin/models/report.js b/app/assets/javascripts/admin/models/report.js index 8157adc0b4..6f8bfdfff3 100644 --- a/app/assets/javascripts/admin/models/report.js +++ b/app/assets/javascripts/admin/models/report.js @@ -92,9 +92,9 @@ Discourse.Report = Discourse.Model.extend({ icon: function() { switch( this.get('type') ) { case 'flags': - return 'fa-flag'; + return 'flag'; case 'likes': - return 'fa-heart'; + return 'heart'; default: return null; } diff --git a/app/assets/javascripts/admin/routes/admin_route.js b/app/assets/javascripts/admin/routes/admin_route.js index e90a18f5fb..07fc597f03 100644 --- a/app/assets/javascripts/admin/routes/admin_route.js +++ b/app/assets/javascripts/admin/routes/admin_route.js @@ -1,8 +1,4 @@ Discourse.AdminRoute = Discourse.Route.extend({ - renderTemplate: function() { - this.render('admin/templates/admin'); - }, - titleToken: function() { return I18n.t('admin_title'); } diff --git a/app/assets/javascripts/admin/routes/admin_user_route.js b/app/assets/javascripts/admin/routes/admin_user_route.js index 5f1d419405..798b2d13d7 100644 --- a/app/assets/javascripts/admin/routes/admin_user_route.js +++ b/app/assets/javascripts/admin/routes/admin_user_route.js @@ -8,7 +8,7 @@ Discourse.AdminUserRoute = Discourse.Route.extend({ }, renderTemplate: function() { - this.render({into: 'admin/templates/admin'}); + this.render({into: 'admin'}); }, afterModel: function(adminUser) { diff --git a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs new file mode 100644 index 0000000000..e91598e5da --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs @@ -0,0 +1,13 @@ + + + {{#if report.icon}} + {{fa-icon report.icon}} + {{/if}} + {{report.title}} + + {{report.todayCount}} + {{report.yesterdayCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{report.lastSevenDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{report.lastThirtyDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{report.total}} + diff --git a/app/assets/javascripts/admin/templates/dashboard.hbs b/app/assets/javascripts/admin/templates/dashboard.hbs index 74a508fe60..0f06bf3782 100644 --- a/app/assets/javascripts/admin/templates/dashboard.hbs +++ b/app/assets/javascripts/admin/templates/dashboard.hbs @@ -16,7 +16,7 @@ {{#unless loading}} - {{ render 'admin/templates/reports/trust_levels_report' users_by_trust_level tagName="tbody" }} + {{ render 'admin/templates/reports/trust_levels_report' users_by_trust_level tagName="tbody"}} {{/unless}} @@ -52,13 +52,13 @@ {{#unless loading}} {{ render 'admin/templates/reports/per_day_counts_report' visits tagName="tbody"}} - {{ render 'admin_report_counts' signups }} - {{ render 'admin_report_counts' topics }} - {{ render 'admin_report_counts' posts }} - {{ render 'admin_report_counts' likes }} - {{ render 'admin_report_counts' flags }} - {{ render 'admin_report_counts' bookmarks }} - {{ render 'admin_report_counts' emails }} + {{admin-report-counts report=signups}} + {{admin-report-counts report=topics}} + {{admin-report-counts report=posts}} + {{admin-report-counts report=likes}} + {{admin-report-counts report=flags}} + {{admin-report-counts report=bookmarks}} + {{admin-report-counts report=emails}} {{/unless}} @@ -76,10 +76,10 @@ {{#unless loading}} - {{ render 'admin_report_counts' page_view_anon_reqs }} - {{ render 'admin_report_counts' page_view_logged_in_reqs }} - {{ render 'admin_report_counts' page_view_crawler_reqs }} - {{ render 'admin_report_counts' page_view_total_reqs }} + {{admin-report-counts report=page_view_anon_reqs}} + {{admin-report-counts report=page_view_logged_in_reqs}} + {{admin-report-counts report=page_view_crawler_reqs}} + {{admin-report-counts report=page_view_total_reqs}} {{/unless}} @@ -98,11 +98,11 @@ {{#unless loading}} - {{ render 'admin_report_counts' user_to_user_private_messages }} - {{ render 'admin_report_counts' system_private_messages }} - {{ render 'admin_report_counts' notify_moderators_private_messages }} - {{ render 'admin_report_counts' notify_user_private_messages }} - {{ render 'admin_report_counts' moderator_warning_private_messages }} + {{admin-report-counts report=user_to_user_private_messages}} + {{admin-report-counts report=system_private_messages}} + {{admin-report-counts report=notify_moderators_private_messages}} + {{admin-report-counts report=notify_user_private_messages}} + {{admin-report-counts report=moderator_warning_private_messages}} {{/unless}} @@ -145,12 +145,12 @@ {{#unless loading}} - {{ render 'admin_report_counts' http_2xx_reqs }} - {{ render 'admin_report_counts' http_3xx_reqs}} - {{ render 'admin_report_counts' http_4xx_reqs}} - {{ render 'admin_report_counts' http_5xx_reqs}} - {{ render 'admin_report_counts' http_background_reqs }} - {{ render 'admin_report_counts' http_total_reqs }} + {{admin-report-counts report=http_2xx_reqs}} + {{admin-report-counts report=http_3xx_reqs}} + {{admin-report-counts report=http_4xx_reqs}} + {{admin-report-counts report=http_5xx_reqs}} + {{admin-report-counts report=http_background_reqs}} + {{admin-report-counts report=http_total_reqs}} {{/unless}} diff --git a/app/assets/javascripts/admin/templates/logs/_site_customization_change_details.hbs b/app/assets/javascripts/admin/templates/logs/_site_customization_change_details.hbs index 1c353f62ad..2c6443e565 100644 --- a/app/assets/javascripts/admin/templates/logs/_site_customization_change_details.hbs +++ b/app/assets/javascripts/admin/templates/logs/_site_customization_change_details.hbs @@ -1,19 +1,73 @@ -
- {{i18n 'admin.customize.css'}}: - {{#if stylesheet}} - ({{i18n 'character_count' count=stylesheet.length}}) - {{/if}} -
- {{textarea value=stylesheet class="plain"}} -
-
- {{i18n 'admin.customize.header'}}: - {{#if header}} - ({{i18n 'character_count' count=header.length}}) - {{/if}} -
- {{textarea value=header class="plain"}} -
{{i18n 'admin.customize.enabled'}}: {{enabled}}
+{{#if stylesheet}} +
+ {{i18n 'admin.customize.css'}}: ({{i18n 'character_count' count=stylesheet.length}}) +
+ {{textarea value=stylesheet class="plain"}} +
+{{/if}} +{{#if mobile_stylesheet}} +
+ {{fa-icon "mobile"}} {{i18n 'admin.customize.css'}}: ({{i18n 'character_count' count=mobile_stylesheet.length}}) +
+ {{textarea value=mobile_stylesheet class="plain"}} +
+{{/if}} +{{#if header}} +
+ {{i18n 'admin.customize.header'}}: ({{i18n 'character_count' count=header.length}}) +
+ {{textarea value=header class="plain"}} +
+{{/if}} +{{#if mobile_header}} +
+ {{fa-icon "mobile"}} {{i18n 'admin.customize.header'}}: ({{i18n 'character_count' count=mobile_header.length}}) +
+ {{textarea value=mobile_header class="plain"}} +
+{{/if}} +{{#if top}} +
+ {{i18n 'admin.customize.top'}}: ({{i18n 'character_count' count=top.length}}) +
+ {{textarea value=top class="plain"}} +
+{{/if}} +{{#if mobile_top}} +
+ {{fa-icon "mobile"}} {{i18n 'admin.customize.top'}}: ({{i18n 'character_count' count=mobile_top.length}}) +
+ {{textarea value=mobile_top class="plain"}} +
+{{/if}} +{{#if footer}} +
+ {{i18n 'admin.customize.footer'}}: ({{i18n 'character_count' count=footer.length}}) +
+ {{textarea value=footer class="plain"}} +
+{{/if}} +{{#if mobile_footer}} +
+ {{fa-icon "mobile"}} {{i18n 'admin.customize.footer'}}: ({{i18n 'character_count' count=mobile_footer.length}}) +
+ {{textarea value=mobile_footer class="plain"}} +
+{{/if}} +{{#if head_tag}} +
+ {{fa-icon "file-text-o"}} {{i18n 'admin.customize.head_tag.text'}}: ({{i18n 'character_count' count=head_tag.length}}) +
+ {{textarea value=head_tag class="plain"}} +
+{{/if}} +{{#if body_tag}} +
+ {{fa-icon "file-text-o"}} {{i18n 'admin.customize.body_tag.text'}}: ({{i18n 'character_count' count=body_tag.length}}) +
+ {{textarea value=body_tag class="plain"}} +
+{{/if}} diff --git a/app/assets/javascripts/admin/templates/reports/summed_counts_report.hbs b/app/assets/javascripts/admin/templates/reports/summed_counts_report.hbs deleted file mode 100644 index b96b3f9b30..0000000000 --- a/app/assets/javascripts/admin/templates/reports/summed_counts_report.hbs +++ /dev/null @@ -1,13 +0,0 @@ - - - {{#if icon}} - - {{/if}} - {{title}} - - {{todayCount}} - {{yesterdayCount}} - {{lastSevenDaysCount}} - {{lastThirtyDaysCount}} - {{total}} - diff --git a/app/assets/javascripts/admin/templates/site_settings/setting_list.hbs b/app/assets/javascripts/admin/templates/site_settings/setting_list.hbs index 802a53324e..2aadfd9845 100644 --- a/app/assets/javascripts/admin/templates/site_settings/setting_list.hbs +++ b/app/assets/javascripts/admin/templates/site_settings/setting_list.hbs @@ -3,6 +3,7 @@
{{list-setting settingValue=value choices=choices settingName=setting}} +
{{validationMessage}}
{{{unbound description}}}
{{#if dirty}} diff --git a/app/assets/javascripts/admin/templates/user_badges.hbs b/app/assets/javascripts/admin/templates/user_badges.hbs index a4bfc236e1..5d55a2d0b5 100644 --- a/app/assets/javascripts/admin/templates/user_badges.hbs +++ b/app/assets/javascripts/admin/templates/user_badges.hbs @@ -9,40 +9,53 @@ {{#loading-spinner condition=loading}}

{{i18n 'admin.badges.grant_badge'}}

+
{{#if noBadges}}

{{i18n 'admin.badges.no_badges'}}

{{else}} -
+
+
+ {{combo-box valueAttribute="id" value=controller.selectedBadgeId content=controller.grantableBadges}} +
+ +
{{/if}} -
-
- -

{{i18n 'admin.badges.granted_badges'}}

-
- - +
+ - {{#each}} + {{#each userBadge in groupedBadges}} - + - + + {{else}} diff --git a/app/assets/javascripts/admin/templates/version_checks.hbs b/app/assets/javascripts/admin/templates/version_checks.hbs index fefee6fec5..9148a4562d 100644 --- a/app/assets/javascripts/admin/templates/version_checks.hbs +++ b/app/assets/javascripts/admin/templates/version_checks.hbs @@ -14,7 +14,7 @@ {{#unless loading}} - + {{#if versionCheck.noCheckPerformed}} diff --git a/app/assets/javascripts/admin/views/admin_backups_view.js b/app/assets/javascripts/admin/views/admin-backups.js.es6 similarity index 91% rename from app/assets/javascripts/admin/views/admin_backups_view.js rename to app/assets/javascripts/admin/views/admin-backups.js.es6 index d1b049f9ac..f0b9f108d7 100644 --- a/app/assets/javascripts/admin/views/admin_backups_view.js +++ b/app/assets/javascripts/admin/views/admin-backups.js.es6 @@ -1,4 +1,4 @@ -Discourse.AdminBackupsView = Discourse.View.extend({ +export default Discourse.View.extend({ classNames: ["admin-backups"], _hijackDownloads: function() { diff --git a/app/assets/javascripts/admin/views/admin.js.es6 b/app/assets/javascripts/admin/views/admin.js.es6 new file mode 100644 index 0000000000..f503a34063 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin.js.es6 @@ -0,0 +1,12 @@ +export default Discourse.View.extend({ + _disableCustomStylesheets: function() { + if (this.session.get("disableCustomCSS")) { + $("link.custom-css").attr("rel", ""); + this.session.set("disableCustomCSS", false); + } + }.on("willInsertElement"), + + _enableCustomStylesheets: function() { + $("link.custom-css").attr("rel", "stylesheet"); + }.on("willDestroyElement") +}); diff --git a/app/assets/javascripts/admin/views/admin_api_view.js b/app/assets/javascripts/admin/views/admin_api_view.js deleted file mode 100644 index f57d84cd1c..0000000000 --- a/app/assets/javascripts/admin/views/admin_api_view.js +++ /dev/null @@ -1,3 +0,0 @@ -Discourse.AdminApiView = Discourse.View.extend({ - templateName: 'admin/templates/api' -}); diff --git a/app/assets/javascripts/admin/views/admin_dashboard_view.js b/app/assets/javascripts/admin/views/admin_dashboard_view.js deleted file mode 100644 index 6bffc189da..0000000000 --- a/app/assets/javascripts/admin/views/admin_dashboard_view.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - The default view in the admin section - - @class AdminDashboardView - @extends Discourse.View - @namespace Discourse - @module Discourse -**/ -Discourse.AdminDashboardView = Discourse.View.extend({ - templateName: 'admin/templates/dashboard' -}); - - diff --git a/app/assets/javascripts/admin/views/report/admin_report_counts_view.js b/app/assets/javascripts/admin/views/report/admin_report_counts_view.js deleted file mode 100644 index fb7bb9c628..0000000000 --- a/app/assets/javascripts/admin/views/report/admin_report_counts_view.js +++ /dev/null @@ -1,4 +0,0 @@ -Discourse.AdminReportCountsView = Discourse.View.extend({ - templateName: 'admin/templates/reports/summed_counts_report', - tagName: 'tbody' -}); diff --git a/app/assets/javascripts/admin/views/site_setting_view.js b/app/assets/javascripts/admin/views/site-setting.js.es6 similarity index 86% rename from app/assets/javascripts/admin/views/site_setting_view.js rename to app/assets/javascripts/admin/views/site-setting.js.es6 index 4f5719d17b..af12ff00b3 100644 --- a/app/assets/javascripts/admin/views/site_setting_view.js +++ b/app/assets/javascripts/admin/views/site-setting.js.es6 @@ -1,12 +1,4 @@ -/** - A view to display a site setting with edit controls - - @class SiteSettingView - @extends Discourse.View - @namespace Discourse - @module Discourse -**/ -Discourse.SiteSettingView = Discourse.View.extend(Discourse.ScrollTop, { +export default Discourse.View.extend(Discourse.ScrollTop, { classNameBindings: [':row', ':setting', 'content.overridden'], preview: function() { diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index c426b54e65..f72cb29427 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -126,3 +126,6 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, { }.property() }); + +// TODO: Remove this, it is in for backwards compatibiltiy with plugins +Discourse.HasCurrentUser = {}; diff --git a/app/assets/javascripts/discourse/adapters/rest.js.es6 b/app/assets/javascripts/discourse/adapters/rest.js.es6 index a01491da97..a00cdd1841 100644 --- a/app/assets/javascripts/discourse/adapters/rest.js.es6 +++ b/app/assets/javascripts/discourse/adapters/rest.js.es6 @@ -1,29 +1,52 @@ const ADMIN_MODELS = ['plugin']; -function plural(type) { - return type + 's'; -} - -function pathFor(type) { - const path = "/" + plural(type); - - if (ADMIN_MODELS.indexOf(type) !== -1) { - return "/admin/" + path; - } - - return path; -} - const _identityMap = {}; +const RestModel = Ember.Object.extend({ + update(attrs) { + const self = this; + return this.store.update(this.get('__type'), this.get('id'), attrs).then(function(result) { + self.setProperties(attrs); + return result; + }); + } +}); + export default Ember.Object.extend({ + serverName(type) { + return Ember.String.underscore(type + 's'); + }, + + pathFor(type, id) { + let path = "/" + this.serverName(type); + + if (ADMIN_MODELS.indexOf(type) !== -1) { path = "/admin/" + path; } + if (id) { path += "/" + id; } + + return path; + }, + findAll(type) { var self = this; - return Discourse.ajax(pathFor(type)).then(function(result) { - return result[plural(type)].map(obj => self._hydrate(type, obj)); + return Discourse.ajax(this.pathFor(type)).then(function(result) { + return result[self.serverName(type)].map(obj => self._hydrate(type, obj)); }); }, + find(type, id) { + var self = this; + return Discourse.ajax(this.pathFor(type, id)).then(function(result) { + return self._hydrate(type, result[self.serverName(type)]); + }); + }, + + update(type, id, attrs) { + const data = {}; + data[this.serverName(type)] = attrs; + + return Discourse.ajax(this.pathFor(type, id), { method: 'PUT', data }); + }, + _hydrate(type, obj) { if (!obj) { throw "Can't hydrate " + type + " of `null`"; } if (!obj.id) { throw "Can't hydrate " + type + " without an `id`"; } @@ -37,7 +60,10 @@ export default Ember.Object.extend({ return existing; } - const klass = this.container.lookupFactory('model:' + type) || Ember.Object; + obj.store = this; + obj.__type = type; + + const klass = this.container.lookupFactory('model:' + type) || RestModel; const model = klass.create(obj); _identityMap[type][obj.id] = model; return model; diff --git a/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 index e61d5b8cff..236091072b 100644 --- a/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 +++ b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 @@ -1,18 +1,11 @@ -/** - A breadcrumb including category drop downs - - @class BreadCrumbsComponent - @extends Ember.Component - @namespace Discourse - @module Discourse -**/ +// A breadcrumb including category drop downs export default Ember.Component.extend({ classNames: ['category-breadcrumb'], tagName: 'ol', parentCategory: Em.computed.alias('category.parentCategory'), parentCategories: Em.computed.filter('categories', function(c) { - if (c.id === Discourse.Site.currentProp("uncategorized_category_id") && !Discourse.SiteSettings.allow_uncategorized_topics) { + if (c.id === this.site.get("uncategorized_category_id") && !this.siteSettings.allow_uncategorized_topics) { // Don't show "uncategorized" if allow_uncategorized_topics setting is false. return false; } diff --git a/app/assets/javascripts/discourse/views/category-notifications-button.js.es6 b/app/assets/javascripts/discourse/components/category-notifications-button.js.es6 similarity index 63% rename from app/assets/javascripts/discourse/views/category-notifications-button.js.es6 rename to app/assets/javascripts/discourse/components/category-notifications-button.js.es6 index b1f9b77985..7be7e1bc97 100644 --- a/app/assets/javascripts/discourse/views/category-notifications-button.js.es6 +++ b/app/assets/javascripts/discourse/components/category-notifications-button.js.es6 @@ -1,16 +1,13 @@ -import NotificationsButton from 'discourse/views/notifications-button'; +import NotificationsButton from 'discourse/components/notifications-button'; export default NotificationsButton.extend({ classNames: ['notification-options', 'category-notification-menu'], buttonIncludesText: false, - longDescriptionBinding: null, hidden: Em.computed.alias('category.deleted'), - notificationLevels: Discourse.Category.NotificationLevel, notificationLevel: Em.computed.alias('category.notification_level'), i18nPrefix: 'category.notifications', - i18nPostfix: '', - clicked: function(id) { + clicked(id) { this.get('category').setNotification(id); } }); diff --git a/app/assets/javascripts/discourse/views/dropdown-button.js.es6 b/app/assets/javascripts/discourse/components/dropdown-button.js.es6 similarity index 67% rename from app/assets/javascripts/discourse/views/dropdown-button.js.es6 rename to app/assets/javascripts/discourse/components/dropdown-button.js.es6 index e656d6424f..3747452e35 100644 --- a/app/assets/javascripts/discourse/views/dropdown-button.js.es6 +++ b/app/assets/javascripts/discourse/components/dropdown-button.js.es6 @@ -1,13 +1,13 @@ import StringBuffer from 'discourse/mixins/string-buffer'; -export default Discourse.View.extend(StringBuffer, { +export default Ember.Component.extend(StringBuffer, { classNameBindings: [':btn-group', 'hidden'], rerenderTriggers: ['text', 'longDescription'], _bindClick: function() { // If there's a click handler, call it if (this.clicked) { - var self = this; + const self = this; this.$().on('click.dropdown-button', 'ul li', function(e) { e.preventDefault(); if ($(e.currentTarget).data('id') !== self.get('activeItem')) { @@ -23,7 +23,7 @@ export default Discourse.View.extend(StringBuffer, { this.$().off('click.dropdown-button', 'ul li'); }.on('willDestroyElement'), - renderString: function(buffer) { + renderString(buffer) { buffer.push("

" + this.get('title') + "

"); buffer.push(""); buffer.push(""); - var desc = this.get('longDescription'); + const desc = this.get('longDescription'); if (desc) { buffer.push("

"); buffer.push(desc); diff --git a/app/assets/javascripts/discourse/components/featured-topic.js.es6 b/app/assets/javascripts/discourse/components/featured-topic.js.es6 new file mode 100644 index 0000000000..def196f7a1 --- /dev/null +++ b/app/assets/javascripts/discourse/components/featured-topic.js.es6 @@ -0,0 +1,11 @@ +export default Ember.Component.extend({ + classNameBindings: [':featured-topic'], + + click(e) { + const $target = $(e.target); + if ($target.closest('.last-posted-at').length) { + this.sendAction('action', {topic: this.get('topic'), position: $target.offset()}); + return false; + } + } +}); diff --git a/app/assets/javascripts/discourse/views/notifications-button.js.es6 b/app/assets/javascripts/discourse/components/notifications-button.js.es6 similarity index 69% rename from app/assets/javascripts/discourse/views/notifications-button.js.es6 rename to app/assets/javascripts/discourse/components/notifications-button.js.es6 index 73ee182752..7c329af4e6 100644 --- a/app/assets/javascripts/discourse/views/notifications-button.js.es6 +++ b/app/assets/javascripts/discourse/components/notifications-button.js.es6 @@ -1,11 +1,11 @@ -import DropdownButtonView from 'discourse/views/dropdown-button'; +import DropdownButton from 'discourse/components/dropdown-button'; +import NotificationLevels from 'discourse/lib/notification-levels'; -export default DropdownButtonView.extend({ +const NotificationsButton = DropdownButton.extend({ classNames: ['notification-options'], title: '', buttonIncludesText: true, activeItem: Em.computed.alias('notificationLevel'), - notificationLevels: [], i18nPrefix: '', i18nPostfix: '', watchingClasses: 'fa fa-exclamation-circle watching', @@ -21,15 +21,14 @@ export default DropdownButtonView.extend({ }.property(), dropDownContent: function() { - var contents = [], - prefix = this.get('i18nPrefix'), - postfix = this.get('i18nPostfix'), - levels = this.get('notificationLevels'); + const contents = [], + prefix = this.get('i18nPrefix'), + postfix = this.get('i18nPostfix'); _.each(this.get('options'), function(pair) { if (postfix === '_pm' && pair[1] === 'regular') { return; } contents.push({ - id: levels[pair[0]], + id: NotificationLevels[pair[0]], title: I18n.t(prefix + '.' + pair[1] + postfix + '.title'), description: I18n.t(prefix + '.' + pair[1] + postfix + '.description'), styleClasses: pair[2] @@ -40,21 +39,20 @@ export default DropdownButtonView.extend({ }.property(), text: function() { - var self = this, - prefix = this.get('i18nPrefix'), - postfix = this.get('i18nPostfix'), - levels = this.get('notificationLevels'); + const self = this, + prefix = this.get('i18nPrefix'), + postfix = this.get('i18nPostfix'); - var key = (function() { + const key = (function() { switch (this.get('notificationLevel')) { - case levels.WATCHING: return 'watching'; - case levels.TRACKING: return 'tracking'; - case levels.MUTED: return 'muted'; + case NotificationLevels.WATCHING: return 'watching'; + case NotificationLevels.TRACKING: return 'tracking'; + case NotificationLevels.MUTED: return 'muted'; default: return 'regular'; } }).call(this); - var icon = (function() { + const icon = (function() { switch (key) { case 'watching': return ' '; case 'tracking': return ' '; @@ -65,8 +63,11 @@ export default DropdownButtonView.extend({ return icon + ( this.get('buttonIncludesText') ? I18n.t(prefix + '.' + key + postfix + ".title") : '') + ""; }.property('notificationLevel'), - clicked: function(/* id */) { + clicked(/* id */) { // sub-class needs to implement this } }); + +export default NotificationsButton; +export { NotificationLevels }; diff --git a/app/assets/javascripts/discourse/views/pinned-button.js.es6 b/app/assets/javascripts/discourse/components/pinned-button.js.es6 similarity index 65% rename from app/assets/javascripts/discourse/views/pinned-button.js.es6 rename to app/assets/javascripts/discourse/components/pinned-button.js.es6 index d3cb74e406..e08586bc52 100644 --- a/app/assets/javascripts/discourse/views/pinned-button.js.es6 +++ b/app/assets/javascripts/discourse/components/pinned-button.js.es6 @@ -1,22 +1,20 @@ -import DropdownButtonView from 'discourse/views/dropdown-button'; +import DropdownButton from 'discourse/components/dropdown-button'; -export default DropdownButtonView.extend({ +export default DropdownButton.extend({ descriptionKey: 'help', classNames: ['pinned-options'], title: '', longDescription: function(){ - var topic = this.get('topic'); - var globally = topic.get('pinned_globally') ? '_globally' : ''; - - var key = 'topic_statuses.' + (topic.get('pinned') ? 'pinned' + globally : 'unpinned') + '.help'; + const topic = this.get('topic'); + const globally = topic.get('pinned_globally') ? '_globally' : ''; + const key = 'topic_statuses.' + (topic.get('pinned') ? 'pinned' + globally : 'unpinned') + '.help'; return I18n.t(key); }.property('topic.pinned'), - topic: Em.computed.alias('controller.model'), target: Em.computed.alias('topic'), hidden: function(){ - var topic = this.get('topic'); + const topic = this.get('topic'); return topic.get('deleted') || (!topic.get('pinned') && !topic.get('unpinned')); }.property('topic.pinned', 'topic.deleted', 'topic.unpinned'), @@ -25,7 +23,7 @@ export default DropdownButtonView.extend({ }.property('topic.pinned'), dropDownContent: function() { - var globally = this.get('topic.pinned_globally') ? '_globally' : ''; + const globally = this.get('topic.pinned_globally') ? '_globally' : ''; return [ {id: 'pinned', title: I18n.t('topic_statuses.pinned' + globally + '.title'), @@ -39,15 +37,15 @@ export default DropdownButtonView.extend({ }.property(), text: function() { - var globally = this.get('topic.pinned_globally') ? '_globally' : ''; - var state = this.get('topic.pinned') ? 'pinned' + globally : 'unpinned'; + const globally = this.get('topic.pinned_globally') ? '_globally' : ''; + const state = this.get('topic.pinned') ? 'pinned' + globally : 'unpinned'; return ' ' + I18n.t('topic_statuses.' + state + '.title') + ""; }.property('topic.pinned', 'topic.unpinned'), - clicked: function(id) { - var topic = this.get('topic'); + clicked(id) { + const topic = this.get('topic'); if(id==='unpinned'){ topic.clearPin(); } else { diff --git a/app/assets/javascripts/discourse/components/topic-notifications-button.js.es6 b/app/assets/javascripts/discourse/components/topic-notifications-button.js.es6 new file mode 100644 index 0000000000..92fa95d5cc --- /dev/null +++ b/app/assets/javascripts/discourse/components/topic-notifications-button.js.es6 @@ -0,0 +1,16 @@ +import NotificationsButton from 'discourse/components/notifications-button'; + +export default NotificationsButton.extend({ + longDescription: Em.computed.alias('topic.details.notificationReasonText'), + hidden: Em.computed.alias('topic.deleted'), + notificationLevel: Em.computed.alias('topic.details.notification_level'), + i18nPrefix: 'topic.notifications', + + i18nPostfix: function() { + return this.get('topic.isPrivateMessage') ? '_pm' : ''; + }.property('topic.isPrivateMessage'), + + clicked(id) { + this.get('topic.details').updateNotifications(id); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/about.js.es6 b/app/assets/javascripts/discourse/controllers/about.js.es6 index f88b331cae..e35882706a 100644 --- a/app/assets/javascripts/discourse/controllers/about.js.es6 +++ b/app/assets/javascripts/discourse/controllers/about.js.es6 @@ -1,9 +1,13 @@ import ObjectController from 'discourse/controllers/object'; export default ObjectController.extend({ + faqOverriden: Ember.computed.gt('siteSettings.faq_url.length', 0), + contactInfo: function() { - if (Discourse.SiteSettings.contact_email) { - return I18n.t('about.contact_info', {contact_email: Discourse.SiteSettings.contact_email}); + if (this.siteSettings.contact_url) { + return I18n.t('about.contact_info', {contact_info: ""+ this.siteSettings.contact_url +""}); + } else if (this.siteSettings.contact_email) { + return I18n.t('about.contact_info', {contact_info: this.siteSettings.contact_email}); } else { return null; } diff --git a/app/assets/javascripts/discourse/controllers/badges/show.js.es6 b/app/assets/javascripts/discourse/controllers/badges/show.js.es6 index fc1934a80f..ed43f55888 100644 --- a/app/assets/javascripts/discourse/controllers/badges/show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/badges/show.js.es6 @@ -48,6 +48,10 @@ export default ObjectController.extend({ _showFooter: function() { this.set("controllers.application.showFooter", !this.get("canLoadMore")); - }.observes("canLoadMore") + }.observes("canLoadMore"), + + showLongDescription: function(){ + return window.location.search.match("long-description"); + }.property('userBadges') }); diff --git a/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 b/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 index 094960251a..206e53e5cf 100644 --- a/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 +++ b/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 @@ -1,3 +1,5 @@ +import NotificationLevels from 'discourse/lib/notification-levels'; + // Support for changing the notification level of various topics export default Em.Controller.extend({ needs: ['topic-bulk-actions'], @@ -5,9 +7,9 @@ export default Em.Controller.extend({ notificationLevels: function() { var result = []; - Object.keys(Discourse.Topic.NotificationLevel).forEach(function(k) { + Object.keys(NotificationLevels).forEach(function(k) { result.push({ - id: Discourse.Topic.NotificationLevel[k].toString(), + id: NotificationLevels[k].toString(), name: I18n.t('topic.notifications.' + k.toLowerCase() + ".title"), description: I18n.t('topic.notifications.' + k.toLowerCase() + ".description") }); diff --git a/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 b/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 index f47829e50a..c380dc849a 100644 --- a/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer-messages.js.es6 @@ -5,50 +5,31 @@ export default Ember.ArrayController.extend({ // Whether we've checked our messages checkedMessages: false, - /** - Initialize the controller - **/ - init: function() { + init() { this._super(); this.reset(); }, actions: { - /** - Closes and hides a message. - - @method closeMessage - @params {Object} message The message to dismiss - **/ - closeMessage: function(message) { + closeMessage(message) { this.removeObject(message); }, - hideMessage: function(message) { - var messagesByTemplate = this.get('messagesByTemplate'), - templateName = message.get('templateName'); - - // kind of hacky but the visibility depends on this - messagesByTemplate[templateName] = undefined; + hideMessage(message) { this.removeObject(message); - } - }, + // kind of hacky but the visibility depends on this + this.get('messagesByTemplate')[message.get('templateName')] = undefined; + }, - /** - Displays a new message + popup(message) { + let messagesByTemplate = this.get('messagesByTemplate'); + const templateName = message.get('templateName'); - @method popup - @params {Object} msg The message to display - **/ - popup: function(msg) { - var messagesByTemplate = this.get('messagesByTemplate'), - templateName = msg.get('templateName'), - existing = messagesByTemplate[templateName]; - - if (!existing) { - this.pushObject(msg); - messagesByTemplate[templateName] = msg; - } + if (!messagesByTemplate[templateName]) { + this.pushObject(message); + messagesByTemplate[templateName] = message; + } + }, }, /** @@ -56,11 +37,13 @@ export default Ember.ArrayController.extend({ @method reset **/ - reset: function() { + reset() { this.clear(); - this.set('messagesByTemplate', {}); - this.set('queuedForTyping', []); - this.set('checkedMessages', false); + this.setProperties({ + messagesByTemplate: {}, + queuedForTyping: [], + checkedMessages: false + }); }, /** @@ -69,11 +52,8 @@ export default Ember.ArrayController.extend({ @method typedReply **/ - typedReply: function() { - var self = this; - this.get('queuedForTyping').forEach(function (msg) { - self.popup(msg); - }); + typedReply() { + this.get('queuedForTyping').forEach(msg => this.popup(msg)); }, /** @@ -82,11 +62,11 @@ export default Ember.ArrayController.extend({ @method queryFor @params {Discourse.Composer} composer The composer model **/ - queryFor: function(composer) { + queryFor(composer) { if (this.get('checkedMessages')) { return; } - var self = this, - queuedForTyping = self.get('queuedForTyping'); + const self = this; + let queuedForTyping = self.get('queuedForTyping'); Discourse.ComposerMessage.find(composer).then(function (messages) { self.set('checkedMessages', true); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 8130901ad0..0ac3813f7d 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -29,18 +29,18 @@ export default DiscourseController.extend({ actions: { // Toggle the reply view - toggle: function() { + toggle() { this.toggle(); }, - togglePreview: function() { + togglePreview() { this.get('model').togglePreview(); }, // Import a quote from the post - importQuote: function() { - var postStream = this.get('topic.postStream'), - postId = this.get('model.post.id'); + importQuote() { + const postStream = this.get('topic.postStream'); + let postId = this.get('model.post.id'); // If there is no current post, use the first post id from the stream if (!postId && postStream) { @@ -49,9 +49,9 @@ export default DiscourseController.extend({ // If we're editing a post, fetch the reply when importing a quote if (this.get('model.editingPost')) { - var replyToPostNumber = this.get('model.post.reply_to_post_number'); + const replyToPostNumber = this.get('model.post.reply_to_post_number'); if (replyToPostNumber) { - var replyPost = postStream.get('posts').findBy('post_number', replyToPostNumber); + const replyPost = postStream.get('posts').findBy('post_number', replyToPostNumber); if (replyPost) { postId = replyPost.get('id'); } @@ -60,34 +60,34 @@ export default DiscourseController.extend({ if (postId) { this.set('model.loading', true); - var composer = this; + const composer = this; return Discourse.Post.load(postId).then(function(post) { - var quote = Discourse.Quote.build(post, post.get("raw")); + const quote = Discourse.Quote.build(post, post.get("raw")); composer.appendBlockAtCursor(quote); composer.set('model.loading', false); }); } }, - cancel: function() { + cancel() { this.cancelComposer(); }, - save: function() { + save() { this.save(); }, - displayEditReason: function() { + displayEditReason() { this.set("showEditReason", true); }, - hitEsc: function() { + hitEsc() { if (this.get('model.viewOpen')) { this.shrink(); } }, - openIfDraft: function() { + openIfDraft() { if (this.get('model.viewDraft')) { this.set('model.composeState', Discourse.Composer.OPEN); } @@ -95,35 +95,33 @@ export default DiscourseController.extend({ }, - updateDraftStatus: function() { - var c = this.get('model'); + updateDraftStatus() { + const c = this.get('model'); if (c) { c.updateDraftStatus(); } }, - appendText: function(text, opts) { - var c = this.get('model'); + appendText(text, opts) { + const c = this.get('model'); if (c) { opts = opts || {}; - var wmd = $('#wmd-input'); - var val = wmd.val() || ''; - var position = opts.position === "cursor" ? wmd.caret() : val.length; + const wmd = $('#wmd-input'), + val = wmd.val() || '', + position = opts.position === "cursor" ? wmd.caret() : val.length, + caret = c.appendText(text, position, opts); - var caret = c.appendText(text, position, opts); - if(wmd[0]){ - Em.run.next(function(){ - Discourse.Utilities.setCaretPosition(wmd[0], caret); - }); + if (wmd[0]) { + Em.run.next(() => Discourse.Utilities.setCaretPosition(wmd[0], caret)); } } }, - appendTextAtCursor: function(text, opts) { + appendTextAtCursor(text, opts) { opts = opts || {}; opts.position = "cursor"; this.appendText(text, opts); }, - appendBlockAtCursor: function(text, opts) { + appendBlockAtCursor(text, opts) { opts = opts || {}; opts.position = "cursor"; opts.block = true; @@ -135,7 +133,7 @@ export default DiscourseController.extend({ }.property(), - toggle: function() { + toggle() { this.closeAutocomplete(); switch (this.get('model.composeState')) { case Discourse.Composer.OPEN: @@ -158,9 +156,9 @@ export default DiscourseController.extend({ return this.get('model.loading'); }.property('model.loading'), - save: function(force) { - var composer = this.get('model'), - self = this; + save(force) { + const composer = this.get('model'), + self = this; // Clear the warning state if we're not showing the checkbox anymore if (!this.get('showWarning')) { @@ -168,7 +166,7 @@ export default DiscourseController.extend({ } if(composer.get('cantSubmitPost')) { - var now = Date.now(); + const now = Date.now(); this.setProperties({ 'view.showTitleTip': now, 'view.showCategoryTip': now, @@ -182,12 +180,12 @@ export default DiscourseController.extend({ // for now handle a very narrow use case // if we are replying to a topic AND not on the topic pop the window up if (!force && composer.get('replyingToTopic')) { - var topic = this.get('topic'); + const topic = this.get('topic'); if (!topic || topic.get('id') !== composer.get('topic.id')) { - var message = I18n.t("composer.posting_not_on_topic"); + const message = I18n.t("composer.posting_not_on_topic"); - var buttons = [{ + let buttons = [{ "label": I18n.t("composer.cancel"), "class": "cancel", "link": true @@ -231,7 +229,7 @@ export default DiscourseController.extend({ opts = opts || {}; self.close(); - var currentUser = Discourse.User.current(); + const currentUser = Discourse.User.current(); if (composer.get('creatingTopic')) { currentUser.set('topic_count', currentUser.get('topic_count') + 1); } else { @@ -255,7 +253,7 @@ export default DiscourseController.extend({ @method checkReplyLength **/ - checkReplyLength: function() { + checkReplyLength() { if (this.present('model.reply')) { // Notify the composer messages controller that a reply has been typed. Some // messages only appear after typing. @@ -269,55 +267,52 @@ export default DiscourseController.extend({ @method findSimilarTopics **/ - findSimilarTopics: function() { - + findSimilarTopics() { // We don't care about similar topics unless creating a topic - if (!this.get('model.creatingTopic')) return; + if (!this.get('model.creatingTopic')) { return; } - var body = this.get('model.reply'), - title = this.get('model.title'), - self = this, - message; + let body = this.get('model.reply'); + const title = this.get('model.title'); // Ensure the fields are of the minimum length - if (body.length < Discourse.SiteSettings.min_body_similar_length || - title.length < Discourse.SiteSettings.min_title_similar_length) { return; } + if (body.length < Discourse.SiteSettings.min_body_similar_length) { return; } + if (title.length < Discourse.SiteSettings.min_title_similar_length) { return; } // TODO pass the 200 in from somewhere body = body.substr(0, 200); // Done search over and over - if((title + body) === this.get('lastSimilaritySearch')) { return; } + if ((title + body) === this.get('lastSimilaritySearch')) { return; } this.set('lastSimilaritySearch', title + body); - var messageController = this.get('controllers.composer-messages'), - similarTopics = this.get('similarTopics'); + const messageController = this.get('controllers.composer-messages'), + similarTopics = this.get('similarTopics'); + + let message = this.get('similarTopicsMessage'); + if (!message) { + message = Discourse.ComposerMessage.create({ + templateName: 'composer/similar_topics', + extraClass: 'similar-topics' + }); + this.set('similarTopicsMessage', message); + } Discourse.Topic.findSimilarTo(title, body).then(function (newTopics) { similarTopics.clear(); similarTopics.pushObjects(newTopics); if (similarTopics.get('length') > 0) { - message = Discourse.ComposerMessage.create({ - templateName: 'composer/similar_topics', - similarTopics: similarTopics, - extraClass: 'similar-topics' - }); - - self.set('similarTopicsMessage', message); - messageController.popup(message); - } else { - message = self.get('similarTopicsMessage'); - if (message) { - messageController.send('hideMessage', message); - } + message.set('similarTopics', similarTopics); + messageController.send("popup", message); + } else if (message) { + messageController.send("hideMessage", message); } }); }, - saveDraft: function() { - var model = this.get('model'); + saveDraft() { + const model = this.get('model'); if (model) { model.saveDraft(); } }, @@ -331,7 +326,7 @@ export default DiscourseController.extend({ @param {Discourse.Topic} [opts.topic] The topic we're replying to @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making **/ - open: function(opts) { + open(opts) { opts = opts || {}; if (!opts.draftKey) { @@ -345,15 +340,17 @@ export default DiscourseController.extend({ this.set('scopedCategoryId', opts.categoryId); } - var composerMessages = this.get('controllers.composer-messages'), - self = this, - composerModel = this.get('model'); + const composerMessages = this.get('controllers.composer-messages'), + self = this; + + let composerModel = this.get('model'); this.setProperties({ showEditReason: false, editReason: null }); composerMessages.reset(); // If we want a different draft than the current composer, close it and clear our model. - if (composerModel && opts.draftKey !== composerModel.draftKey && + if (composerModel && + opts.draftKey !== composerModel.draftKey && composerModel.composeState === Discourse.Composer.DRAFT) { this.close(); composerModel = null; @@ -396,7 +393,7 @@ export default DiscourseController.extend({ }, // Given a potential instance and options, set the model for this composer. - _setModel: function(composerModel, opts) { + _setModel(composerModel, opts) { if (opts.draft) { composerModel = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft); if (composerModel) { @@ -411,26 +408,25 @@ export default DiscourseController.extend({ composerModel.set('composeState', Discourse.Composer.OPEN); composerModel.set('isWarning', false); - var composerMessages = this.get('controllers.composer-messages'); - composerMessages.queryFor(composerModel); + this.get('controllers.composer-messages').queryFor(composerModel); }, // View a new reply we've made - viewNewReply: function() { + viewNewReply() { Discourse.URL.routeTo(this.get('createdPost.url')); this.close(); return false; }, - destroyDraft: function() { - var key = this.get('model.draftKey'); + destroyDraft() { + const key = this.get('model.draftKey'); if (key) { Discourse.Draft.clear(key, this.get('model.draftSequence')); } }, - cancelComposer: function() { - var self = this; + cancelComposer() { + const self = this; return new Ember.RSVP.Promise(function (resolve) { if (self.get('model.hasMetaData') || self.get('model.replyDirty')) { @@ -454,7 +450,7 @@ export default DiscourseController.extend({ }, - shrink: function() { + shrink() { if (this.get('model.replyDirty')) { this.collapse(); } else { @@ -462,12 +458,12 @@ export default DiscourseController.extend({ } }, - collapse: function() { + collapse() { this.saveDraft(); this.set('model.composeState', Discourse.Composer.DRAFT); }, - close: function() { + close() { this.setProperties({ model: null, 'view.showTitleTip': false, @@ -476,11 +472,11 @@ export default DiscourseController.extend({ }); }, - closeAutocomplete: function() { + closeAutocomplete() { $('#wmd-input').autocomplete({ cancel: true }); }, - showOptions: function() { + showOptions() { var _ref; return (_ref = this.get('controllers.modal')) ? _ref.show(Discourse.ArchetypeOptionsModalView.create({ archetype: this.get('model.archetype'), diff --git a/app/assets/javascripts/discourse/controllers/controller.js.es6 b/app/assets/javascripts/discourse/controllers/controller.js.es6 index 91fabd7471..94c3ba1d43 100644 --- a/app/assets/javascripts/discourse/controllers/controller.js.es6 +++ b/app/assets/javascripts/discourse/controllers/controller.js.es6 @@ -1 +1 @@ -export default Ember.Controller.extend(Discourse.Presence, Discourse.HasCurrentUser); +export default Ember.Controller.extend(Discourse.Presence); diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index eb15fa0262..03dc3d4f61 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -310,12 +310,26 @@ export default DiscourseController.extend(ModalFunctionality, { }); } + if (!this.blank('accountUsername') && this.get('accountPassword') === this.get('accountUsername')) { + return Discourse.InputValidation.create({ + failed: true, + reason: I18n.t('user.password.same_as_username') + }); + } + + if (!this.blank('accountEmail') && this.get('accountPassword') === this.get('accountEmail')) { + return Discourse.InputValidation.create({ + failed: true, + reason: I18n.t('user.password.same_as_email') + }); + } + // Looks good! return Discourse.InputValidation.create({ ok: true, reason: I18n.t('user.password.ok') }); - }.property('accountPassword', 'rejectedPasswords.@each'), + }.property('accountPassword', 'rejectedPasswords.@each', 'accountUsername', 'accountEmail'), fetchConfirmationValue: function() { var createAccountController = this; diff --git a/app/assets/javascripts/discourse/controllers/discovery.js.es6 b/app/assets/javascripts/discourse/controllers/discovery.js.es6 index 0817323504..45fa7d2957 100644 --- a/app/assets/javascripts/discourse/controllers/discovery.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery.js.es6 @@ -14,7 +14,7 @@ export default ObjectController.extend({ this.set("controllers.application.showFooter", this.get("loadedAllItems")); }.observes("loadedAllItems"), - showMoreUrl: function(period) { + showMoreUrl(period) { var url = '', category = this.get('category'); if (category) { url = '/c/' + Discourse.Category.slugFor(category) + (this.get('noSubcategories') ? '/none' : '') + '/l'; @@ -24,12 +24,12 @@ export default ObjectController.extend({ }, periods: function() { - var self = this, - periods = []; - Discourse.Site.currentProp('periods').forEach(function(p) { + const self = this, + periods = []; + this.site.get('periods').forEach(function(p) { periods.pushObject(TopPeriod.create({ id: p, showMoreUrl: self.showMoreUrl(p), - periods: periods })); + periods })); }); return periods; }.property('category', 'noSubcategories'), diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index 1e23c9154a..37078d492d 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -7,7 +7,8 @@ export default DiscoveryController.extend({ showPostsColumn: Em.computed.empty('withLogo'), actions: { - refresh: function() { + + refresh() { // Don't refresh if we're still loading if (this.get('controllers.discovery.loading')) { return; } @@ -17,15 +18,11 @@ export default DiscoveryController.extend({ // Lesson learned: Don't call `loading` yourself. this.set('controllers.discovery.loading', true); - var parentCategory = this.get('model.parentCategory'); - var promise; - if (parentCategory) { - promise = Discourse.CategoryList.listForParent(parentCategory); - } else { - promise = Discourse.CategoryList.list(); - } + const parentCategory = this.get('model.parentCategory'); + const promise = parentCategory ? Discourse.CategoryList.listForParent(parentCategory) : + Discourse.CategoryList.list(); - var self = this; + const self = this; promise.then(function(list) { self.set('model', list); self.send('loadingComplete'); diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 520e517078..dd7d2568ca 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -1,5 +1,6 @@ import DiscoveryController from 'discourse/controllers/discovery'; import { queryParams } from 'discourse/controllers/discovery-sortable'; +import NotificationLevels from 'discourse/lib/notification-levels'; var controllerOpts = { needs: ['discovery'], @@ -86,7 +87,7 @@ var controllerOpts = { operation = { type: 'dismiss_posts' }; } else { operation = { type: 'change_notification_level', - notification_level_id: Discourse.Topic.NotificationLevel.REGULAR }; + notification_level_id: NotificationLevels.REGULAR }; } var promise; diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index 53bb84b1b6..ed47740aa0 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -17,13 +17,13 @@ export default ObjectController.extend(ModalFunctionality, { _.each(this.get("actions_summary"),function(a) { var actionSummary; a.flagTopic = self.get('model'); - a.actionType = Discourse.Site.current().topicFlagTypeById(a.id); + a.actionType = self.site.topicFlagTypeById(a.id); actionSummary = Discourse.ActionSummary.create(a); lookup.set(a.actionType.get('name_key'), actionSummary); }); this.set('topicActionByName', lookup); - return Discourse.Site.currentProp('topic_flag_types').filter(function(item) { + return this.site.get('topic_flag_types').filter(function(item) { return _.any(self.get("actions_summary"), function(a) { return (a.id === item.get('id') && a.can_act); }); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 9b94de7b6a..e4c2cd6bc0 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -16,10 +16,6 @@ export default DiscourseController.extend(ModalFunctionality, { this.set('loggedIn', false); }, - site: function() { - return Discourse.Site.current(); - }.property(), - /** Determines whether at least one login button is enabled **/ @@ -168,6 +164,12 @@ export default DiscourseController.extend(ModalFunctionality, { this.set('authenticate', null); return; } + if (options.admin_not_allowed_from_ip_address) { + this.send('showLogin'); + this.flash(I18n.t('login.admin_not_allowed_from_ip_address'), 'success'); + this.set('authenticate', null); + return; + } if (options.not_allowed_from_ip_address) { this.send('showLogin'); this.flash(I18n.t('login.not_allowed_from_ip_address'), 'success'); diff --git a/app/assets/javascripts/discourse/controllers/notification.js.es6 b/app/assets/javascripts/discourse/controllers/notification.js.es6 index 12b3734121..256eccf9c2 100644 --- a/app/assets/javascripts/discourse/controllers/notification.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notification.js.es6 @@ -5,7 +5,7 @@ var INVITED_TYPE= 8; export default ObjectController.extend({ scope: function () { - return "notifications." + Discourse.Site.currentProp("notificationLookup")[this.get("notification_type")]; + return "notifications." + this.site.get("notificationLookup")[this.get("notification_type")]; }.property("notification_type"), username: Em.computed.alias("data.display_username"), diff --git a/app/assets/javascripts/discourse/controllers/notifications.js.es6 b/app/assets/javascripts/discourse/controllers/notifications.js.es6 index 300d22b01a..1b2c71574c 100644 --- a/app/assets/javascripts/discourse/controllers/notifications.js.es6 +++ b/app/assets/javascripts/discourse/controllers/notifications.js.es6 @@ -1,4 +1,4 @@ -export default Ember.ArrayController.extend(Discourse.HasCurrentUser, { +export default Ember.ArrayController.extend({ needs: ['header'], loadingNotifications: Em.computed.alias('controllers.header.loadingNotifications') }); diff --git a/app/assets/javascripts/discourse/controllers/object.js.es6 b/app/assets/javascripts/discourse/controllers/object.js.es6 index 56bef4c519..75a4714285 100644 --- a/app/assets/javascripts/discourse/controllers/object.js.es6 +++ b/app/assets/javascripts/discourse/controllers/object.js.es6 @@ -1 +1 @@ -export default Ember.ObjectController.extend(Discourse.Presence, Discourse.HasCurrentUser); +export default Ember.ObjectController.extend(Discourse.Presence); diff --git a/app/assets/javascripts/discourse/controllers/site-map-category.js.es6 b/app/assets/javascripts/discourse/controllers/site-map-category.js.es6 index cf10e9d6bb..52da588ccf 100644 --- a/app/assets/javascripts/discourse/controllers/site-map-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/site-map-category.js.es6 @@ -1,4 +1,4 @@ -export default Ember.ObjectController.extend(Discourse.HasCurrentUser, { +export default Ember.ObjectController.extend({ needs: ['site-map'], unreadTotal: function() { diff --git a/app/assets/javascripts/discourse/controllers/site-map.js.es6 b/app/assets/javascripts/discourse/controllers/site-map.js.es6 index 8503d90781..9ee464c789 100644 --- a/app/assets/javascripts/discourse/controllers/site-map.js.es6 +++ b/app/assets/javascripts/discourse/controllers/site-map.js.es6 @@ -1,4 +1,4 @@ -export default Ember.ArrayController.extend(Discourse.HasCurrentUser, { +export default Ember.ArrayController.extend({ needs: ['application'], showBadgesLink: function(){return Discourse.SiteSettings.enable_badges;}.property(), diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 40cfab14ee..5f85584714 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -435,10 +435,10 @@ export default ObjectController.extend(Discourse.SelectedPostsCount, BufferedCon post.toggleProperty('wiki'); }, - togglePostType: function (post) { + togglePostType(post) { // the request to the server is made in an observer in the post class - var regular = Discourse.Site.currentProp('post_types.regular'), - moderator = Discourse.Site.currentProp('post_types.moderator_action'); + const regular = this.site.get('post_types.regular'), + moderator = this.site.get('post_types.moderator_action'); if (post.get("post_type") === moderator) { post.set("post_type", regular); diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6 index 7701837f38..ac4d451d50 100644 --- a/app/assets/javascripts/discourse/controllers/user-card.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-card.js.es6 @@ -71,7 +71,7 @@ export default ObjectController.extend({ this.setProperties({ user: null, userLoading: username, cardTarget: target }); var self = this; - Discourse.User.findByUsername(username).then(function (user) { + Discourse.User.findByUsername(username, {stats: false}).then(function (user) { user = Discourse.User.create(user); self.setProperties({ user: user, avatar: user, visible: true}); self.appEvents.trigger('usercard:shown'); diff --git a/app/assets/javascripts/discourse/controllers/user-dropdown.js.es6 b/app/assets/javascripts/discourse/controllers/user-dropdown.js.es6 index 3413e61f27..af91f6c6f1 100644 --- a/app/assets/javascripts/discourse/controllers/user-dropdown.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-dropdown.js.es6 @@ -1,8 +1,8 @@ -export default Ember.ArrayController.extend(Discourse.HasCurrentUser, { +export default Ember.ArrayController.extend({ showAdminLinks: Em.computed.alias("currentUser.staff"), actions: { - logout: function() { + logout() { Discourse.logout(); return false; } diff --git a/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 b/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 index 2d252ac156..5bab37f838 100644 --- a/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 @@ -1,7 +1,7 @@ import ObjectController from 'discourse/controllers/object'; // Lists of topics on a user's page. -export default ObjectController.extend(Discourse.HasCurrentUser, { +export default ObjectController.extend({ needs: ["application", "user"], hideCategory: false, showParticipants: false, diff --git a/app/assets/javascripts/discourse/ember/resolver.js.es6 b/app/assets/javascripts/discourse/ember/resolver.js.es6 index d6ce2e2122..f0e091589c 100644 --- a/app/assets/javascripts/discourse/ember/resolver.js.es6 +++ b/app/assets/javascripts/discourse/ember/resolver.js.es6 @@ -171,6 +171,11 @@ export default Ember.DefaultResolver.extend({ const compTemplate = Ember.TEMPLATES['admin/templates/' + decamelized]; if (compTemplate) { return compTemplate; } } + + if (decamelized === "javascripts/admin") { + return Ember.TEMPLATES['admin/templates/admin']; + } + if (decamelized.indexOf('admin') === 0 || decamelized.indexOf('javascripts/admin') === 0) { decamelized = decamelized.replace(/^admin\_/, 'admin/templates/'); decamelized = decamelized.replace(/^admin\./, 'admin/templates/'); diff --git a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 index f91800fb0b..e979ebab90 100644 --- a/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 +++ b/app/assets/javascripts/discourse/helpers/fa-icon.js.es6 @@ -1,24 +1,27 @@ import registerUnbound from 'discourse/helpers/register-unbound'; -function iconClasses(icon, modifier) { +function iconClasses(icon, params) { var classes = "fa fa-" + icon; - if (modifier) { classes += " fa-" + modifier; } + if (params.modifier) { classes += " fa-" + params.modifier; } + if (params['class']) { classes += ' ' + params['class']; } return classes; } -function iconHTML(icon, label, modifier) { - var html = ""; + if (params.label) { + html += "" + I18n.t(params.label) + ""; } return html; } registerUnbound('fa-icon', function(icon, params) { - return new Handlebars.SafeString(iconHTML(icon, params.label, params.modifier)); + return new Handlebars.SafeString(iconHTML(icon, params)); }); -export { iconClasses, iconHTML }; +export { iconHTML }; diff --git a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 index fc37aad17e..7950eb467b 100644 --- a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 +++ b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 @@ -42,9 +42,10 @@ export default { app.inject('view', 'session', 'session:main'); app.inject('model', 'session', 'session:main'); - // Inject currentUser. Components only for now to prevent any breakage app.register('current-user:main', Discourse.User.current(), { instantiate: false }); app.inject('component', 'currentUser', 'current-user:main'); + app.inject('route', 'currentUser', 'current-user:main'); + app.inject('controller', 'currentUser', 'current-user:main'); app.register('store:main', Store); app.inject('route', 'store', 'store:main'); diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js index 8109805b87..6e5c1185d4 100644 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ b/app/assets/javascripts/discourse/lib/Markdown.Editor.js @@ -1672,14 +1672,7 @@ // sure the URL and the optinal title are "nice". function properlyEncoded(linkdef) { return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) { - link = link.replace(/\?.*$/, function (querypart) { - return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical - }); - link = decodeURIComponent(link); // unencode first, to prevent double encoding - link = encodeURI(link).replace(/#/g, '%23').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); - link = link.replace(/\?.*$/, function (querypart) { - return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded - }); + link = link.replace(/ /g, '%20').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); if (title) { title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, ""); title = title.replace(/"/g, "quot;").replace(/\(/g, "(").replace(/\)/g, ")").replace(//g, ">"); diff --git a/app/assets/javascripts/discourse/lib/click_track.js b/app/assets/javascripts/discourse/lib/click_track.js index f53d0e1ed0..9672c860e8 100644 --- a/app/assets/javascripts/discourse/lib/click_track.js +++ b/app/assets/javascripts/discourse/lib/click_track.js @@ -5,6 +5,7 @@ @namespace Discourse @module Discourse **/ + Discourse.ClickTrack = { /** @@ -43,15 +44,11 @@ Discourse.ClickTrack = { if (!ownLink) { var $badge = $('span.badge', $link); if ($badge.length === 1) { - // don't update counts in category badge - if ($link.closest('.badge-category').length === 0) { - // nor in oneboxes (except when we force it) - if (($link.closest(".onebox-result").length === 0 && $link.closest('.onebox-body').length === 0) || $link.hasClass("track-link")) { - var html = $badge.html(); - if (/^\d+$/.test(html)) { - $badge.html(parseInt(html, 10) + 1); - } - } + // don't update counts in category badge nor in oneboxes (except when we force it) + if ($link.hasClass("track-link") || + $link.closest('.badge-category,.onebox-result,.onebox-body').length === 0) { + var html = $badge.html(); + if (/^\d+$/.test(html)) { $badge.html(parseInt(html, 10) + 1); } } } } diff --git a/app/assets/javascripts/discourse/lib/notification-levels.js.es6 b/app/assets/javascripts/discourse/lib/notification-levels.js.es6 new file mode 100644 index 0000000000..f6830ec7d3 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/notification-levels.js.es6 @@ -0,0 +1,6 @@ +export default { + WATCHING: 3, + TRACKING: 2, + REGULAR: 1, + MUTED: 0 +}; diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index 266ed03ba6..9f7832cf46 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -313,6 +313,7 @@ Discourse.Utilities = { } else { return new Ember.RSVP.Promise(function(resolve) { var image = document.createElement("img"); + image.crossOrigin = 'Anonymous'; // this event will be fired as soon as the image is loaded image.onload = function(e) { var img = e.target; diff --git a/app/assets/javascripts/discourse/mixins/has_current_user.js b/app/assets/javascripts/discourse/mixins/has_current_user.js deleted file mode 100644 index 92d5461a1f..0000000000 --- a/app/assets/javascripts/discourse/mixins/has_current_user.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - This mixin provides a `currentUser` property that can be used to retrieve information - about the currently logged in user. It is mostly useful to controllers so it can be - exposted to templates. -**/ -Discourse.HasCurrentUser = Em.Mixin.create({ - - currentUser: function() { - return Discourse.User.current(); - }.property().volatile() - -}); diff --git a/app/assets/javascripts/discourse/models/category.js b/app/assets/javascripts/discourse/models/category.js index 64a92a9039..d01e93110d 100644 --- a/app/assets/javascripts/discourse/models/category.js +++ b/app/assets/javascripts/discourse/models/category.js @@ -192,13 +192,6 @@ var _uncategorized; Discourse.Category.reopenClass({ - NotificationLevel: { - WATCHING: 3, - TRACKING: 2, - REGULAR: 1, - MUTED: 0 - }, - findUncategorized: function() { _uncategorized = _uncategorized || Discourse.Category.list().findBy('id', Discourse.Site.currentProp('uncategorized_category_id')); return _uncategorized; diff --git a/app/assets/javascripts/discourse/models/site.js b/app/assets/javascripts/discourse/models/site.js.es6 similarity index 74% rename from app/assets/javascripts/discourse/models/site.js rename to app/assets/javascripts/discourse/models/site.js.es6 index 93263d3810..1831da9895 100644 --- a/app/assets/javascripts/discourse/models/site.js +++ b/app/assets/javascripts/discourse/models/site.js.es6 @@ -1,9 +1,9 @@ -Discourse.Site = Discourse.Model.extend({ +const Site = Discourse.Model.extend({ isReadOnly: Em.computed.alias('is_readonly'), notificationLookup: function() { - var result = []; + const result = []; _.each(this.get('notification_types'), function(v,k) { result[v] = k; }); @@ -11,23 +11,23 @@ Discourse.Site = Discourse.Model.extend({ }.property('notification_types'), flagTypes: function() { - var postActionTypes = this.get('post_action_types'); + const postActionTypes = this.get('post_action_types'); if (!postActionTypes) return []; return postActionTypes.filterProperty('is_flag', true); }.property('post_action_types.@each'), - categoriesByCount: Em.computed.sort('categories', function(a, b) { + categoriesByCount: Ember.computed.sort('categories', function(a, b) { return (b.get('topic_count') || 0) - (a.get('topic_count') || 0); }), // Sort subcategories under parents sortedCategories: function() { - var cats = this.get('categoriesByCount'), + const cats = this.get('categoriesByCount'), result = [], remaining = {}; cats.forEach(function(c) { - var parentCategoryId = parseInt(c.get('parent_category_id'), 10); + const parentCategoryId = parseInt(c.get('parent_category_id'), 10); if (!parentCategoryId) { result.pushObject(c); } else { @@ -37,7 +37,7 @@ Discourse.Site = Discourse.Model.extend({ }); Ember.keys(remaining).forEach(function(parentCategoryId) { - var category = result.findBy('id', parseInt(parentCategoryId, 10)), + const category = result.findBy('id', parseInt(parentCategoryId, 10)), index = result.indexOf(category); if (index !== -1) { @@ -48,16 +48,16 @@ Discourse.Site = Discourse.Model.extend({ return result; }.property(), - postActionTypeById: function(id) { + postActionTypeById(id) { return this.get("postActionByIdLookup.action" + id); }, - topicFlagTypeById: function(id) { + topicFlagTypeById(id) { return this.get("topicFlagByIdLookup.action" + id); }, - updateCategory: function(newCategory) { - var existingCategory = this.get('categories').findProperty('id', Em.get(newCategory, 'id')); + updateCategory(newCategory) { + const existingCategory = this.get('categories').findProperty('id', Em.get(newCategory, 'id')); if (existingCategory) { // Don't update null permissions if (newCategory.permission === null) { delete newCategory.permission; } @@ -67,20 +67,15 @@ Discourse.Site = Discourse.Model.extend({ } }); -Discourse.Site.reopenClass(Discourse.Singleton, { +Site.reopenClass(Discourse.Singleton, { - /** - The current singleton will retrieve its attributes from the `PreloadStore`. - - @method createCurrent - @returns {Discourse.Site} the site - **/ - createCurrent: function() { + // The current singleton will retrieve its attributes from the `PreloadStore`. + createCurrent() { return Discourse.Site.create(PreloadStore.get('site')); }, - create: function() { - var result = this._super.apply(this, arguments); + create() { + const result = this._super.apply(this, arguments); if (result.categories) { result.categoriesById = {}; @@ -107,7 +102,7 @@ Discourse.Site.reopenClass(Discourse.Singleton, { if (result.post_action_types) { result.postActionByIdLookup = Em.Object.create(); result.post_action_types = _.map(result.post_action_types,function(p) { - var actionType = Discourse.PostActionType.create(p); + const actionType = Discourse.PostActionType.create(p); result.postActionByIdLookup.set("action" + p.id, actionType); return actionType; }); @@ -116,7 +111,7 @@ Discourse.Site.reopenClass(Discourse.Singleton, { if (result.topic_flag_types) { result.topicFlagByIdLookup = Em.Object.create(); result.topic_flag_types = _.map(result.topic_flag_types,function(p) { - var actionType = Discourse.PostActionType.create(p); + const actionType = Discourse.PostActionType.create(p); result.topicFlagByIdLookup.set("action" + p.id, actionType); return actionType; }); @@ -138,4 +133,4 @@ Discourse.Site.reopenClass(Discourse.Singleton, { } }); - +export default Site; diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index fed39da1df..8fcd0d86ae 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -2,5 +2,11 @@ export default Ember.Object.extend({ findAll(type) { const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); return adapter.findAll(type); + }, + + find(type, id) { + const adapter = this.container.lookup('adapter:' + type) || this.container.lookup('adapter:rest'); + return adapter.find(type, id); } + }); diff --git a/app/assets/javascripts/discourse/models/topic_tracking_state.js b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 similarity index 75% rename from app/assets/javascripts/discourse/models/topic_tracking_state.js rename to app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 index b0b1dab17f..947f0ba187 100644 --- a/app/assets/javascripts/discourse/models/topic_tracking_state.js +++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 @@ -1,16 +1,18 @@ -function isNew(topic){ +import NotificationLevels from 'discourse/lib/notification-levels'; + +function isNew(topic) { return topic.last_read_post_number === null && ((topic.notification_level !== 0 && !topic.notification_level) || - topic.notification_level >= Discourse.Topic.NotificationLevel.TRACKING); + topic.notification_level >= NotificationLevels.TRACKING); } -function isUnread(topic){ +function isUnread(topic) { return topic.last_read_post_number !== null && topic.last_read_post_number < topic.highest_post_number && - topic.notification_level >= Discourse.Topic.NotificationLevel.TRACKING; + topic.notification_level >= NotificationLevels.TRACKING; } -Discourse.TopicTrackingState = Discourse.Model.extend({ +const TopicTrackingState = Discourse.Model.extend({ messageCount: 0, _setup: function() { @@ -19,17 +21,17 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ this.states = {}; }.on('init'), - establishChannels: function() { - var tracker = this; + establishChannels() { + const tracker = this; - var process = function(data){ + const process = function(data){ if (data.message_type === "delete") { tracker.removeTopic(data.topic_id); tracker.incrementMessageCount(); } if (data.message_type === "new_topic" || data.message_type === "latest") { - var ignored_categories = Discourse.User.currentProp("muted_category_ids"); + const ignored_categories = Discourse.User.currentProp("muted_category_ids"); if(_.include(ignored_categories, data.payload.category_id)){ return; } @@ -41,7 +43,7 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ if (data.message_type === "new_topic" || data.message_type === "unread" || data.message_type === "read") { tracker.notify(data); - var old = tracker.states["t" + data.topic_id]; + const old = tracker.states["t" + data.topic_id]; if(!_.isEqual(old, data.payload)){ tracker.states["t" + data.topic_id] = data.payload; @@ -52,32 +54,32 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ Discourse.MessageBus.subscribe("/new", process); Discourse.MessageBus.subscribe("/latest", process); - var currentUser = Discourse.User.current(); + const currentUser = Discourse.User.current(); if(currentUser) { Discourse.MessageBus.subscribe("/unread/" + currentUser.id, process); } }, - updateSeen: function(topicId, highestSeen) { + updateSeen(topicId, highestSeen) { if(!topicId || !highestSeen) { return; } - var state = this.states["t" + topicId]; + const state = this.states["t" + topicId]; if(state && (!state.last_read_post_number || state.last_read_post_number < highestSeen)) { state.last_read_post_number = highestSeen; this.incrementMessageCount(); } }, - notify: function(data){ + notify(data){ if (!this.newIncoming) { return; } - var filter = this.get("filter"); + const filter = this.get("filter"); if ((filter === "all" || filter === "latest" || filter === "new") && data.message_type === "new_topic" ) { this.addIncoming(data.topic_id); } if ((filter === "all" || filter === "unread") && data.message_type === "unread") { - var old = this.states["t" + data.topic_id]; + const old = this.states["t" + data.topic_id]; if(!old || old.highest_post_number === old.last_read_post_number) { this.addIncoming(data.topic_id); } @@ -90,47 +92,47 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ this.set("incomingCount", this.newIncoming.length); }, - addIncoming: function(topicId) { + addIncoming(topicId) { if(this.newIncoming.indexOf(topicId) === -1){ this.newIncoming.push(topicId); } }, - resetTracking: function(){ + resetTracking(){ this.newIncoming = []; this.set("incomingCount", 0); }, // track how many new topics came for this filter - trackIncoming: function(filter) { + trackIncoming(filter) { this.newIncoming = []; this.set("filter", filter); this.set("incomingCount", 0); }, hasIncoming: function(){ - var count = this.get('incomingCount'); + const count = this.get('incomingCount'); return count && count > 0; }.property('incomingCount'), - removeTopic: function(topic_id) { + removeTopic(topic_id) { delete this.states["t" + topic_id]; }, // If we have a cached topic list, we can update it from our tracking // information. - updateTopics: function(topics) { + updateTopics(topics) { if (Em.isEmpty(topics)) { return; } - var states = this.states; + const states = this.states; topics.forEach(function(t) { - var state = states['t' + t.get('id')]; + const state = states['t' + t.get('id')]; if (state) { - var lastRead = t.get('last_read_post_number'); + const lastRead = t.get('last_read_post_number'); if (lastRead !== state.last_read_post_number) { - var postsCount = t.get('posts_count'), - newPosts = postsCount - state.highest_post_number, + const postsCount = t.get('posts_count'); + let newPosts = postsCount - state.highest_post_number, unread = postsCount - state.last_read_post_number; if (newPosts < 0) { newPosts = 0; } @@ -151,16 +153,16 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ }); }, - sync: function(list, filter) { - var tracker = this, + sync(list, filter) { + const tracker = this, states = tracker.states; if (!list || !list.topics) { return; } // compensate for delayed "new" topics // client side we know they are not new, server side we think they are - for (var i=list.topics.length-1; i>=0; i--) { - var state = states["t"+ list.topics[i].id]; + for (let i=list.topics.length-1; i>=0; i--) { + const state = states["t"+ list.topics[i].id]; if (state && state.last_read_post_number > 0) { if (filter === "new") { list.topics.splice(i, 1); @@ -172,7 +174,7 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ } list.topics.forEach(function(topic){ - var row = tracker.states["t" + topic.id] || {}; + const row = tracker.states["t" + topic.id] || {}; row.topic_id = topic.id; row.notification_level = topic.notification_level; @@ -199,7 +201,7 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ // Correct missing states, safeguard in case message bus is corrupt if((filter === "new" || filter === "unread") && !list.more_topics_url){ - var ids = {}; + const ids = {}; list.topics.forEach(function(r){ ids["t" + r.id] = true; }); @@ -224,11 +226,11 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ this.incrementMessageCount(); }, - incrementMessageCount: function() { + incrementMessageCount() { this.set("messageCount", this.get("messageCount") + 1); }, - countNew: function(category_id){ + countNew(category_id){ return _.chain(this.states) .where(isNew) .where(function(topic){ return topic.category_id === category_id || !category_id;}) @@ -236,8 +238,8 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ .length; }, - resetNew: function() { - var self = this; + resetNew() { + const self = this; Object.keys(this.states).forEach(function (id) { if (self.states[id].last_read_post_number === null) { delete self.states[id]; @@ -245,7 +247,7 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ }); }, - countUnread: function(category_id){ + countUnread(category_id){ return _.chain(this.states) .where(isUnread) .where(function(topic){ return topic.category_id === category_id || !category_id;}) @@ -253,19 +255,19 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ .length; }, - countCategory: function(category_id) { - var count = 0; + countCategory(category_id) { + let sum = 0; _.each(this.states, function(topic){ if (topic.category_id === category_id) { - count += (topic.last_read_post_number === null || + sum += (topic.last_read_post_number === null || topic.last_read_post_number < topic.highest_post_number) ? 1 : 0; } }); - return count; + return sum; }, - lookupCount: function(name, category){ - var categoryName = category ? Em.get(category, "name") : null; + lookupCount(name, category){ + let categoryName = category ? Em.get(category, "name") : null; if(name === "new") { return this.countNew(categoryName); } else if(name === "unread") { @@ -277,9 +279,9 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ } } }, - loadStates: function (data) { + loadStates(data) { // not exposed - var states = this.states; + const states = this.states; if(data) { _.each(data,function(topic){ @@ -290,19 +292,21 @@ Discourse.TopicTrackingState = Discourse.Model.extend({ }); -Discourse.TopicTrackingState.reopenClass({ - createFromStates: function(data){ - var instance = Discourse.TopicTrackingState.create(); +TopicTrackingState.reopenClass({ + createFromStates(data){ + const instance = Discourse.TopicTrackingState.create(); instance.loadStates(data); instance.establishChannels(); return instance; }, - current: function(){ + current(){ if (!this.tracker) { - var data = PreloadStore.get('topicTrackingStates'); + const data = PreloadStore.get('topicTrackingStates'); this.tracker = this.createFromStates(data); PreloadStore.remove('topicTrackingStates'); } return this.tracker; } }); + +export default TopicTrackingState; diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 7dff779589..5ae5ee7ec4 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -26,6 +26,10 @@ const Topic = Discourse.Model.extend({ return PostStream.create({topic: this}); }.property(), + replyCount: function() { + return this.get('posts_count') - 1; + }.property('posts_count'), + details: function() { return TopicDetails.create({topic: this}); }.property(), @@ -411,7 +415,6 @@ Topic.reopenClass({ }); return Discourse.ajax(topic.get('url'), { type: 'PUT', data: props }).then(function(result) { - // The title can be cleaned up server side props.title = result.basic_topic.title; props.fancy_title = result.basic_topic.fancy_title; diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index c650d1704e..e25139ecab 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -292,11 +292,11 @@ Discourse.User = Discourse.Model.extend({ return this.get('stats').rejectProperty('isPM'); }.property('stats.@each.isPM'), - findDetails: function() { + findDetails: function(options) { var user = this; return PreloadStore.getAndRemove("user_" + user.get('username'), function() { - return Discourse.ajax("/users/" + user.get('username') + '.json'); + return Discourse.ajax("/users/" + user.get('username') + '.json', {data: options}); }).then(function (json) { if (!Em.isEmpty(json.user.stats)) { @@ -468,9 +468,9 @@ Discourse.User.reopenClass(Discourse.Singleton, { @method findByUsername @returns {Promise} a promise that resolves to a `Discourse.User` **/ - findByUsername: function(username) { + findByUsername: function(username, options) { var user = Discourse.User.create({username: username}); - return user.findDetails(); + return user.findDetails(options); }, /** diff --git a/app/assets/javascripts/discourse/models/user_badge.js b/app/assets/javascripts/discourse/models/user_badge.js index 9297f5e523..158e70e46c 100644 --- a/app/assets/javascripts/discourse/models/user_badge.js +++ b/app/assets/javascripts/discourse/models/user_badge.js @@ -7,6 +7,11 @@ @module Discourse **/ Discourse.UserBadge = Discourse.Model.extend({ + postUrl: function() { + if(this.get('topic_title')) { + return "/t/-/" + this.get('topic_id') + "/" + this.get('post_number'); + } + }.property(), // avoid the extra bindings for now /** Revoke this badge. @@ -93,7 +98,7 @@ Discourse.UserBadge.reopenClass({ @returns {Promise} a promise that resolves to an array of `Discourse.UserBadge`. **/ findByUsername: function(username, options) { - var url = "/users/" + username + "/badges_json.json"; + var url = "/user-badges/" + username + ".json"; if (options && options.grouped) { url += "?grouped=true"; } @@ -128,12 +133,13 @@ Discourse.UserBadge.reopenClass({ @param {String} username username of the user to be granted the badge. @returns {Promise} a promise that resolves to an instance of `Discourse.UserBadge`. **/ - grant: function(badgeId, username) { + grant: function(badgeId, username, reason) { return Discourse.ajax("/user_badges", { type: "POST", data: { username: username, - badge_id: badgeId + badge_id: badgeId, + reason: reason } }).then(function(json) { return Discourse.UserBadge.createFromJson(json); diff --git a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 index ae70acd7d6..e3567ef850 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -28,8 +28,7 @@ export default function(filter, params) { category: model, filterMode: filterMode, noSubcategories: params && params.no_subcategories, - canEditCategory: model.get('can_edit'), - canChangeCategoryNotificationLevel: Discourse.User.current() + canEditCategory: model.get('can_edit') }); }, diff --git a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 index 683b53cc6c..84127c3a85 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -2,7 +2,7 @@ import { queryParams } from 'discourse/controllers/discovery-sortable'; // A helper to build a topic route for a filter function filterQueryParams(params, defaultParams) { - var findOpts = defaultParams || {}; + const findOpts = defaultParams || {}; if (params) { Ember.keys(queryParams).forEach(function(opt) { if (params[opt]) { findOpts[opt] = params[opt]; } @@ -16,47 +16,47 @@ export default function(filter, extras) { return Discourse.Route.extend({ queryParams: queryParams, - beforeModel: function() { + beforeModel() { this.controllerFor('navigation/default').set('filterMode', filter); }, - model: function(data, transition) { + model(data, transition) { // attempt to stop early cause we need this to be called before .sync Discourse.ScreenTrack.current().stop(); - var findOpts = filterQueryParams(transition.queryParams), - extras = { cached: this.isPoppedState(transition) }; + const findOpts = filterQueryParams(transition.queryParams), + extras = { cached: this.isPoppedState(transition) }; return Discourse.TopicList.list(filter, findOpts, extras); }, - titleToken: function() { + titleToken() { if (filter === Discourse.Utilities.defaultHomepage()) { return; } - var filterText = I18n.t('filters.' + filter.replace('/', '.') + '.title', {count: 0}); + const filterText = I18n.t('filters.' + filter.replace('/', '.') + '.title', {count: 0}); return I18n.t('filters.with_topics', {filter: filterText}); }, - setupController: function(controller, model, trans) { + setupController(controller, model, trans) { if (trans) { controller.setProperties(Em.getProperties(trans, _.keys(queryParams).map(function(v){ return 'queryParams.' + v; }))); } - var periods = this.controllerFor('discovery').get('periods'), - periodId = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''); + const periods = this.controllerFor('discovery').get('periods'), + periodId = model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''); - var topicOpts = { - model: model, + const topicOpts = { + model, category: null, period: periods.findBy('id', periodId), selected: [], expandGloballyPinned: true }; - var params = model.get('params'); + const params = model.get('params'); if (params && Object.keys(params).length) { topicOpts.order = params.order; topicOpts.ascending = params.ascending; @@ -67,7 +67,7 @@ export default function(filter, extras) { this.controllerFor('navigation/default').set('canCreateTopic', model.get('can_create_topic')); }, - renderTemplate: function() { + renderTemplate() { this.render('navigation/default', { outlet: 'navigation-bar' }); this.render('discovery/topics', { controller: 'discovery/topics', outlet: 'list-container' }); } diff --git a/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 b/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 index 31278dfca5..c30f26a6b3 100644 --- a/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery-categories-route.js.es6 @@ -1,22 +1,22 @@ import ShowFooter from "discourse/mixins/show-footer"; Discourse.DiscoveryCategoriesRoute = Discourse.Route.extend(Discourse.OpenComposer, ShowFooter, { - renderTemplate: function() { + renderTemplate() { this.render('navigation/categories', { outlet: 'navigation-bar' }); this.render('discovery/categories', { outlet: 'list-container' }); }, - beforeModel: function() { + beforeModel() { this.controllerFor('navigation/categories').set('filterMode', 'categories'); }, - model: function() { + model() { // TODO: Remove this and ensure server side does not supply `topic_list` // if default page is categories PreloadStore.remove("topic_list"); return Discourse.CategoryList.list('categories').then(function(list) { - var tracking = Discourse.TopicTrackingState.current(); + const tracking = Discourse.TopicTrackingState.current(); if (tracking) { tracking.sync(list, 'categories'); tracking.trackIncoming('categories'); @@ -25,11 +25,12 @@ Discourse.DiscoveryCategoriesRoute = Discourse.Route.extend(Discourse.OpenCompos }); }, - titleToken: function() { + titleToken() { + if (Discourse.Utilities.defaultHomepage() === "categories") { return; } return I18n.t('filters.categories.title'); }, - setupController: function(controller, model) { + setupController(controller, model) { controller.set('model', model); // Only show either the Create Category or Create Topic button @@ -40,22 +41,19 @@ Discourse.DiscoveryCategoriesRoute = Discourse.Route.extend(Discourse.OpenCompos }, actions: { - createCategory: function() { - var groups = Discourse.Site.current().groups; - var everyone_group = groups.findBy('id', 0); - var group_names = groups.map(function(group) { - return group.name; - }); + createCategory() { + const groups = this.site.groups, + everyoneName = groups.findBy('id', 0).name; Discourse.Route.showModal(this, 'editCategory', Discourse.Category.create({ - color: 'AB9364', text_color: 'FFFFFF', group_permissions: [{group_name: everyone_group.name, permission_type: 1}], - available_groups: group_names, + color: 'AB9364', text_color: 'FFFFFF', group_permissions: [{group_name: everyoneName, permission_type: 1}], + available_groups: groups.map(g => g.name), allow_badges: true })); this.controllerFor('editCategory').set('selectedTab', 'general'); }, - createTopic: function() { + createTopic() { this.openComposer(this.controllerFor('discovery/categories')); } } diff --git a/app/assets/javascripts/discourse/templates/about.hbs b/app/assets/javascripts/discourse/templates/about.hbs index 077f8fb721..b555a33486 100644 --- a/app/assets/javascripts/discourse/templates/about.hbs +++ b/app/assets/javascripts/discourse/templates/about.hbs @@ -1,82 +1,97 @@ -

-
-

{{i18n 'about.title' title=title}}

-

{{description}}

-
+
+
- {{#if admins}} -
-

{{i18n 'about.our_admins'}}

- - {{#each a in admins}} - {{user-small user=a}} - {{/each}} -
+ +
+

{{i18n 'about.title' title=title}}

+

{{description}}

- {{/if}} - {{#if moderators}} -
-

{{i18n 'about.our_moderators'}}

+ {{#if admins}} +
+

{{i18n 'about.our_admins'}}

-
- {{#each m in moderators}} - {{user-small user=m}} + {{#each a in admins}} + {{user-small user=a}} {{/each}} -
-
+
+ +
+ {{/if}} + + {{#if moderators}} +
+

{{i18n 'about.our_moderators'}}

+ +
+ {{#each m in moderators}} + {{user-small user=m}} + {{/each}} +
+
+
+ {{/if}} + +
+

{{i18n 'about.stats'}}

+ +
{{i18n 'admin.badges.badge'}} {{i18n 'admin.badges.granted_by'}}{{i18n 'admin.badges.reason'}} {{i18n 'admin.badges.granted_at'}}
{{user-badge badge=badge}}{{user-badge badge=userBadge.badge count=userBadge.count}} - {{#link-to 'adminUser' badge.granted_by}} - {{avatar granted_by imageSize="tiny"}} - {{granted_by.username}} + {{#link-to 'adminUser' userBadge.badge.granted_by}} + {{avatar userBadge.granted_by imageSize="tiny"}} + {{userBadge.granted_by.username}} {{/link-to}} {{age-with-tooltip granted_at}} + {{#if userBadge.postUrl}} + {{userBadge.topic_title}} + {{/if}} + {{age-with-tooltip userBadge.granted_at}} - + {{#if userBadge.grouped}} + + {{else}} + + {{/if}}
{{i18n 'admin.dashboard.version'}}{{ versionCheck.installed_version }}{{ versionCheck.installed_describe }} 
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 {{i18n 'about.stat.all_time'}}{{i18n 'about.stat.last_7_days'}}{{i18n 'about.stat.last_30_days'}}
{{i18n 'about.topic_count'}}{{number stats.topic_count}}{{number stats.topics_7_days}}{{number stats.topics_30_days}}
{{i18n 'about.post_count'}}{{number stats.post_count}}{{number stats.posts_7_days}}{{number stats.posts_30_days}}
{{i18n 'about.user_count'}}{{number stats.user_count}}{{number stats.users_7_days}}{{number stats.users_30_days}}
{{i18n 'about.active_user_count'}}{{number stats.active_users_7_days}}{{number stats.active_users_30_days}}
{{i18n 'about.like_count'}}{{number stats.like_count}}{{number stats.likes_7_days}}{{number stats.likes_30_days}}
- {{/if}} -
-

{{i18n 'about.stats'}}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 {{i18n 'about.stat.all_time'}}{{i18n 'about.stat.last_7_days'}}{{i18n 'about.stat.last_30_days'}}
{{i18n 'about.topic_count'}}{{number stats.topic_count}}{{number stats.topics_7_days}}{{number stats.topics_30_days}}
{{i18n 'about.post_count'}}{{number stats.post_count}}{{number stats.posts_7_days}}{{number stats.posts_30_days}}
{{i18n 'about.user_count'}}{{number stats.user_count}}{{number stats.users_7_days}}{{number stats.users_30_days}}
{{i18n 'about.active_user_count'}}{{number stats.active_users_7_days}}{{number stats.active_users_30_days}}
{{i18n 'about.like_count'}}{{number stats.like_count}}{{number stats.likes_7_days}}{{number stats.likes_30_days}}
-
- - {{#if contactInfo}} -
-

{{i18n 'about.contact'}}

-

{{contactInfo}}

-
- {{/if}} + {{#if contactInfo}} +
+

{{i18n 'about.contact'}}

+

{{{contactInfo}}}

+
+ {{/if}} +
diff --git a/app/assets/javascripts/discourse/templates/badges/show.hbs b/app/assets/javascripts/discourse/templates/badges/show.hbs index c686df798e..98910de960 100644 --- a/app/assets/javascripts/discourse/templates/badges/show.hbs +++ b/app/assets/javascripts/discourse/templates/badges/show.hbs @@ -17,6 +17,12 @@ + {{#if showLongDescription}} + + {{/if}} + {{#if userBadges}}
{{#each ub in userBadges}} diff --git a/app/assets/javascripts/discourse/templates/category_notification_dropdown.hbs b/app/assets/javascripts/discourse/templates/category_notification_dropdown.hbs deleted file mode 100644 index a8610862d2..0000000000 --- a/app/assets/javascripts/discourse/templates/category_notification_dropdown.hbs +++ /dev/null @@ -1,17 +0,0 @@ -
- - - - -
diff --git a/app/assets/javascripts/discourse/templates/components/featured-topic.hbs b/app/assets/javascripts/discourse/templates/components/featured-topic.hbs new file mode 100644 index 0000000000..e047595301 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/featured-topic.hbs @@ -0,0 +1,13 @@ +{{topic-status topic=topic}} +{{{unbound topic.fancy_title}}} +{{topic-post-badges newPosts=topic.totalUnread unseen=topic.unseen url=topic.lastUnreadUrl}} + +{{#if latestTopicOnly}} + +{{else}} +   + {{format-age topic.last_posted_at}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/user-fields/text.hbs b/app/assets/javascripts/discourse/templates/components/user-fields/text.hbs index 499752add5..f008167a80 100644 --- a/app/assets/javascripts/discourse/templates/components/user-fields/text.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-fields/text.hbs @@ -1,6 +1,6 @@
- {{input value=value}} + {{input value=value maxlength=site.user_field_max_length}} {{#if field.required}}*{{/if}}

{{{field.description}}}

diff --git a/app/assets/javascripts/discourse/templates/components/user-small.hbs b/app/assets/javascripts/discourse/templates/components/user-small.hbs index 674f8f7590..ac22ea37ae 100644 --- a/app/assets/javascripts/discourse/templates/components/user-small.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-small.hbs @@ -1,4 +1,9 @@ -{{#link-to 'user' user.username}} - {{avatar user imageSize="tiny"}} - {{user.username}} -{{/link-to}} +
+ {{#link-to 'user' user.username}}{{avatar user imageSize="large"}}{{/link-to}} +
+ +
+ {{#link-to 'user' user.username}}{{user.username}}{{/link-to}} + {{user.name}} + {{user.title}} +
diff --git a/app/assets/javascripts/discourse/templates/composer/similar_topics.hbs b/app/assets/javascripts/discourse/templates/composer/similar_topics.hbs index 1ab70a4f9c..616ac8e7c6 100644 --- a/app/assets/javascripts/discourse/templates/composer/similar_topics.hbs +++ b/app/assets/javascripts/discourse/templates/composer/similar_topics.hbs @@ -1,4 +1,4 @@ - +{{fa-icon "times-circle"}}

{{i18n 'composer.similar_topics'}}

-{{#if canChangeCategoryNotificationLevel}} - {{view 'category-notifications-button' category=category}} +{{#if currentUser}} + {{category-notifications-button category=category}} {{/if}} {{#if canCreateTopic}} diff --git a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs index 762822cf70..4714a3cedb 100644 --- a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs +++ b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs @@ -10,7 +10,7 @@ {{#if showPosters}} {{raw "topic-list-header-column" order='posters' name='users'}} {{/if}} -{{raw "topic-list-header-column" sortable=sortable number='true' order='posts' name='posts'}} +{{raw "topic-list-header-column" sortable=sortable number='true' order='posts' name='replies'}} {{#if showParticipants}} {{raw "topic-list-header-column" order='participants' name='users'}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index 884de10217..4a617f999a 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -145,6 +145,10 @@
+ {{#each uf in userFields}} + {{user-field field=uf.field value=uf.value}} + {{/each}} +
@@ -212,10 +216,6 @@ {{plugin-outlet "user_custom_preferences"}}
- {{#each uf in userFields}} - {{user-field field=uf.field value=uf.value}} - {{/each}} -
diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index 42b620b185..543705053a 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -3,7 +3,8 @@ import userSearch from 'discourse/lib/user-search'; import afterTransition from 'discourse/lib/after-transition'; -var ComposerView = Discourse.View.extend(Ember.Evented, { +const ComposerView = Discourse.View.extend(Ember.Evented, { + _lastKeyTimeout: null, templateName: 'composer', elementId: 'reply-control', classNameBindings: ['model.creatingPrivateMessage:private-message', @@ -48,12 +49,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { Ember.run.scheduleOnce('afterRender', this, 'refreshPreview'); }.observes('model.reply', 'model.hidePreview'), - focusIn: function() { - var controller = this.get('controller'); + focusIn() { + const controller = this.get('controller'); if (controller) controller.updateDraftStatus(); }, - movePanels: function(sizePx) { + movePanels(sizePx) { $('#main-outlet').css('padding-bottom', sizePx); $('.composer-popup').css('bottom', sizePx); // signal the progress bar it should move! @@ -61,14 +62,14 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, resize: function() { - var self = this; + const self = this; Em.run.scheduleOnce('afterRender', function() { - var h = $('#reply-control').height() || 0; + const h = $('#reply-control').height() || 0; self.movePanels.apply(self, [h + "px"]); // Figure out the size of the fields - var $fields = self.$('.composer-fields'), - pos = $fields.position(); + const $fields = self.$('.composer-fields'); + let pos = $fields.position(); if (pos) { self.$('.wmd-controls').css('top', $fields.height() + pos.top + 5); @@ -83,17 +84,19 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }); }.observes('model.composeState', 'model.action'), - keyUp: function() { - var controller = this.get('controller'); + keyUp() { + const controller = this.get('controller'); controller.checkReplyLength(); - var lastKeyUp = new Date(); + const lastKeyUp = new Date(); this.set('lastKeyUp', lastKeyUp); // One second from now, check to see if the last key was hit when // we recorded it. If it was, the user paused typing. - var self = this; - Em.run.later(function() { + const self = this; + + Ember.run.cancel(this._lastKeyTimeout); + this._lastKeyTimeout = Ember.run.later(function() { if (lastKeyUp !== self.get('lastKeyUp')) return; // Search for similar topics if the user pauses typing @@ -101,7 +104,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, 1000); }, - keyDown: function(e) { + keyDown(e) { if (e.which === 27) { // ESC this.get('controller').send('hitEsc'); @@ -114,12 +117,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, _enableResizing: function() { - var $replyControl = $('#reply-control'), + const $replyControl = $('#reply-control'), self = this; $replyControl.DivResizer({ resize: this.resize.bind(self), - onDrag: function (sizePx) { self.movePanels.apply(self, [sizePx]); } + onDrag(sizePx) { self.movePanels.apply(self, [sizePx]); } }); afterTransition($replyControl, this.resize.bind(self)); this.ensureMaximumDimensionForImagesInPreview(); @@ -130,14 +133,14 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { this.set('controller.view', null); }.on('willDestroyElement'), - ensureMaximumDimensionForImagesInPreview: function() { + ensureMaximumDimensionForImagesInPreview() { // This enforce maximum dimensions of images in the preview according // to the current site settings. // For interactivity, we immediately insert the locally cooked version // of the post into the stream when the user hits reply. We therefore also // need to enforce these rules on the .cooked version. // Meanwhile, the server is busy post-processing the post and generating thumbnails. - var style = Discourse.Mobile.mobileView ? + const style = Discourse.Mobile.mobileView ? 'max-width: 100%; height: auto;' : 'max-width:' + Discourse.SiteSettings.max_image_width + 'px;' + 'max-height:' + Discourse.SiteSettings.max_image_height + 'px;'; @@ -145,17 +148,17 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { $('').appendTo('head'); }, - click: function() { + click() { this.get('controller').send('openIfDraft'); }, // Called after the preview renders. Debounced for performance - afterRender: function() { - var $wmdPreview = $('#wmd-preview'); + afterRender() { + const $wmdPreview = $('#wmd-preview'); if ($wmdPreview.length === 0) return; - var post = this.get('model.post'), - refresh = false; + const post = this.get('model.post'); + let refresh = false; // If we are editing a post, we'll refresh its contents once. This is a feature that // allows a user to refresh its contents once. @@ -175,17 +178,17 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { this.trigger('previewRefreshed', $wmdPreview); }, - _applyEmojiAutocomplete: function() { + _applyEmojiAutocomplete() { if (!this.siteSettings.enable_emoji) { return; } - var template = this.container.lookup('template:emoji-selector-autocomplete.raw'); + const template = this.container.lookup('template:emoji-selector-autocomplete.raw'); $('#wmd-input').autocomplete({ template: template, key: ":", - transformComplete: function(v){ return v.code + ":"; }, - dataSource: function(term){ + transformComplete(v) { return v.code + ":"; }, + dataSource(term){ return new Ember.RSVP.Promise(function(resolve) { - var full = ":" + term; + const full = ":" + term; term = term.toLowerCase(); if (term === "") { @@ -196,7 +199,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { return resolve([Discourse.Emoji.translations[full]]); } - var options = Discourse.Emoji.search(term, {maxResults: 5}); + const options = Discourse.Emoji.search(term, {maxResults: 5}); return resolve(options); }).then(function(list) { @@ -208,10 +211,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }); }, - initEditor: function() { + initEditor() { // not quite right, need a callback to pass in, meaning this gets called once, // but if you start replying to another topic it will get the avatars wrong - var $wmdInput, editor, self = this; + let $wmdInput, editor; + const self = this; this.wmdInput = $wmdInput = $('#wmd-input'); if ($wmdInput.length === 0 || $wmdInput.data('init') === true) return; @@ -219,11 +223,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { ComposerView.trigger("initWmdEditor"); this._applyEmojiAutocomplete(); - var template = this.container.lookup('template:user-selector-autocomplete.raw'); + const template = this.container.lookup('template:user-selector-autocomplete.raw'); $wmdInput.data('init', true); $wmdInput.autocomplete({ template: template, - dataSource: function(term) { + dataSource(term) { return userSearch({ term: term, topicId: self.get('controller.controllers.topic.model.id'), @@ -231,7 +235,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }); }, key: "@", - transformComplete: function(v) { + transformComplete(v) { if (v.username) { return v.username; } else { @@ -241,10 +245,10 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }); this.editor = editor = Discourse.Markdown.createEditor({ - lookupAvatarByPostNumber: function(postNumber) { - var posts = self.get('controller.controllers.topic.postStream.posts'); + lookupAvatarByPostNumber(postNumber) { + const posts = self.get('controller.controllers.topic.postStream.posts'); if (posts) { - var quotedPost = posts.findProperty("post_number", postNumber); + const quotedPost = posts.findProperty("post_number", postNumber); if (quotedPost) { return Discourse.Utilities.tinyAvatar(quotedPost.get("avatar_template")); } @@ -273,7 +277,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { this.set('editor', this.editor); this.loadingChanged(); - var saveDraft = Discourse.debounce((function() { + const saveDraft = Discourse.debounce((function() { return self.get('controller').saveDraft(); }), 2000); @@ -282,7 +286,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { return true; }); - var $replyTitle = $('#reply-title'); + const $replyTitle = $('#reply-title'); $replyTitle.keyup(function() { saveDraft(); @@ -305,9 +309,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // in case it's still bound somehow this._unbindUploadTarget(); - var $uploadTarget = $('#reply-control'), - csrf = Discourse.Session.currentProp('csrfToken'), - cancelledByTheUser; + const $uploadTarget = $('#reply-control'), + csrf = Discourse.Session.currentProp('csrfToken'); + let cancelledByTheUser; // NOTE: we need both the .json extension and the CSRF token as a query parameter for IE9 $uploadTarget.fileupload({ @@ -318,7 +322,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // submit - this event is triggered for each upload $uploadTarget.on('fileuploadsubmit', function (e, data) { - var result = Discourse.Utilities.validateUploadedFiles(data.files); + const result = Discourse.Utilities.validateUploadedFiles(data.files); // reset upload status when everything is ok if (result) self.setProperties({ uploadProgress: 0, isUploading: true }); return result; @@ -331,7 +335,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { self.get('controller').send('closeModal'); // NOTE: IE9 doesn't support XHR if (data["xhr"]) { - var jqHXR = data.xhr(); + const jqHXR = data.xhr(); if (jqHXR) { // need to wait for the link to show up in the DOM Em.run.schedule('afterRender', function() { @@ -351,7 +355,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // progress all $uploadTarget.on('fileuploadprogressall', function (e, data) { - var progress = parseInt(data.loaded / data.total * 100, 10); + const progress = parseInt(data.loaded / data.total * 100, 10); self.set('uploadProgress', progress); }); @@ -360,7 +364,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { if (!cancelledByTheUser) { // make sure we have a url if (data.result.url) { - var markdown = Discourse.Utilities.getUploadMarkdown(data.result); + const markdown = Discourse.Utilities.getUploadMarkdown(data.result); // appends a space at the end of the inserted markdown self.addMarkdown(markdown + " "); self.set('isUploading', false); @@ -385,7 +389,7 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // Firefox. This is pretty dangerous because it can potentially break // Ctrl+v to paste so we should be conservative about what browsers this runs // in. - var uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); + const uaMatch = navigator.userAgent.match(/Firefox\/(\d+)\.\d/); if (uaMatch && parseInt(uaMatch[1]) >= 24) { self.$().append( Ember.$("
") ); self.$("textarea").off('keydown.contenteditable'); @@ -395,18 +399,18 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // after we switch focus, probably because it is being executed too late. if ((event.ctrlKey || event.metaKey) && (event.keyCode === 86)) { // Save the current textarea selection. - var textarea = self.$("textarea")[0], + const textarea = self.$("textarea")[0], selectionStart = textarea.selectionStart, selectionEnd = textarea.selectionEnd; // Focus the contenteditable div. - var contentEditableDiv = self.$('#contenteditable'); + const contentEditableDiv = self.$('#contenteditable'); contentEditableDiv.focus(); // The paste doesn't finish immediately and we don't have any onpaste // event, so wait for 100ms which _should_ be enough time. setTimeout(function() { - var pastedImg = contentEditableDiv.find('img'); + const pastedImg = contentEditableDiv.find('img'); if ( pastedImg.length === 1 ) { pastedImg.remove(); @@ -414,11 +418,11 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // For restoring the selection. textarea.focus(); - var textareaContent = $(textarea).val(), + const textareaContent = $(textarea).val(), startContent = textareaContent.substring(0, selectionStart), endContent = textareaContent.substring(selectionEnd); - var restoreSelection = function(pastedText) { + const restoreSelection = function(pastedText) { $(textarea).val( startContent + pastedText + endContent ); textarea.selectionStart = selectionStart + pastedText.length; textarea.selectionEnd = textarea.selectionStart; @@ -435,20 +439,20 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { // to a Blob and upload that, but if it is a regular URL that // operation is prevented for security purposes. When we get a regular // URL let's just create an tag for the image. - var imageSrc = pastedImg.attr('src'); + const imageSrc = pastedImg.attr('src'); if (imageSrc.match(/^data:image/)) { // Restore the cursor position, and remove any selected text. restoreSelection(""); // Create a Blob to upload. - var image = new Image(); + const image = new Image(); image.onload = function() { // Create a new canvas. - var canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); + const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); canvas.height = image.height; canvas.width = image.width; - var ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0); canvas.toBlob(function(blob) { @@ -488,8 +492,8 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, 400); }, - addMarkdown: function(text) { - var ctrl = $('#wmd-input').get(0), + addMarkdown(text) { + const ctrl = $('#wmd-input').get(0), caretPosition = Discourse.Utilities.caretPosition(ctrl), current = this.get('model.reply'); this.set('model.reply', current.substring(0, caretPosition) + text + current.substring(caretPosition, current.length)); @@ -500,10 +504,10 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, // Uses javascript to get the image sizes from the preview, if present - imageSizes: function() { - var result = {}; + imageSizes() { + const result = {}; $('#wmd-preview img').each(function(i, e) { - var $img = $(e), + const $img = $(e), src = $img.prop('src'); if (src && src.length) { @@ -513,12 +517,12 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { return result; }, - childDidInsertElement: function() { + childDidInsertElement() { return this.initEditor(); }, - childWillDestroyElement: function() { - var self = this; + childWillDestroyElement() { + const self = this; this._unbindUploadTarget(); @@ -532,9 +536,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }, titleValidation: function() { - var titleLength = this.get('model.titleLength'), - missingChars = this.get('model.missingTitleCharacters'), - reason; + const titleLength = this.get('model.titleLength'), + missingChars = this.get('model.missingTitleCharacters'); + let reason; if( titleLength < 1 ){ reason = I18n.t('composer.error.title_missing'); } else if( missingChars > 0 ) { @@ -555,9 +559,9 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { }.property('model.categoryId'), replyValidation: function() { - var replyLength = this.get('model.replyLength'), - missingChars = this.get('model.missingReplyCharacters'), - reason; + const replyLength = this.get('model.replyLength'), + missingChars = this.get('model.missingReplyCharacters'); + let reason; if( replyLength < 1 ){ reason = I18n.t('composer.error.post_missing'); } else if( missingChars > 0 ) { @@ -569,8 +573,8 @@ var ComposerView = Discourse.View.extend(Ember.Evented, { } }.property('model.reply', 'model.replyLength', 'model.missingReplyCharacters', 'model.minimumPostLength'), - _unbindUploadTarget: function() { - var $uploadTarget = $('#reply-control'); + _unbindUploadTarget() { + const $uploadTarget = $('#reply-control'); try { $uploadTarget.fileupload('destroy'); } catch (e) { /* wasn't initialized yet */ } $uploadTarget.off(); diff --git a/app/assets/javascripts/discourse/views/container.js.es6 b/app/assets/javascripts/discourse/views/container.js.es6 index f538c90210..f8cbc3bd60 100644 --- a/app/assets/javascripts/discourse/views/container.js.es6 +++ b/app/assets/javascripts/discourse/views/container.js.es6 @@ -1,26 +1,11 @@ export default Ember.ContainerView.extend(Discourse.Presence, { - /** - Attaches a view and wires up the container properly - - @method attachViewWithArgs - @param {Object} viewArgs The arguments to pass when creating the view - @param {Class} viewClass The view class we want to create - **/ - attachViewWithArgs: function(viewArgs, viewClass) { + attachViewWithArgs(viewArgs, viewClass) { if (!viewClass) { viewClass = Ember.View.extend(); } - var view = this.createChildView(viewClass, viewArgs); - this.pushObject(view); + this.pushObject(this.createChildView(viewClass, viewArgs)); }, - /** - Attaches a view with no arguments and wires up the container properly - - @method attachViewClass - @param {Class} viewClass The view class we want to add - **/ - attachViewClass: function(viewClass) { + attachViewClass(viewClass) { this.attachViewWithArgs(null, viewClass); } - }); diff --git a/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6 b/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6 index af020c59aa..19bed7d0d2 100644 --- a/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6 +++ b/app/assets/javascripts/discourse/views/list/posts-count-column.js.es6 @@ -11,7 +11,7 @@ export default Ember.Object.extend({ title: function() { return I18n.messageFormat('posts_likes_MF', { - count: this.get('topic.posts_count'), + count: this.get('topic.replyCount'), ratio: this.get('ratioText') }).trim(); }.property(), diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index 422771c061..42bf9dfc4e 100644 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -48,12 +48,12 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { Em.run.scheduleOnce('afterRender', this, '_cookedWasChanged'); }.observes('post.cooked'), - _cookedWasChanged: function() { + _cookedWasChanged() { this.trigger('postViewUpdated', this.$()); this._insertQuoteControls(); }, - mouseUp: function(e) { + mouseUp(e) { if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) { this.get('controller').toggledSelectedPost(this.get('post')); } @@ -74,7 +74,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { repliesShown: Em.computed.gt('post.replies.length', 0), - _updateQuoteElements: function($aside, desc) { + _updateQuoteElements($aside, desc) { var navLink = "", quoteTitle = I18n.t("post.follow_quote"), postNumber = $aside.data('post'); @@ -108,7 +108,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { $('.quote-controls', $aside).html(expandContract + navLink); }, - _toggleQuote: function($aside) { + _toggleQuote($aside) { if (this.get('expanding')) { return; } this.set('expanding', true); @@ -151,23 +151,29 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { }, // Show how many times links have been clicked on - _showLinkCounts: function() { - var self = this, - link_counts = this.get('post.link_counts'); + _showLinkCounts() { + const self = this, + link_counts = this.get('post.link_counts'); - if (!link_counts) return; + if (!link_counts) { return; } link_counts.forEach(function(lc) { - if (!lc.clicks || lc.clicks < 1) return; + if (!lc.clicks || lc.clicks < 1) { return; } self.$(".cooked a[href]").each(function() { - var link = $(this); - if ((!lc.internal || lc.url[0] === "/") && link.attr('href') === lc.url) { - // don't display badge counts on category badge - if (link.closest('.badge-category').length === 0 && ((link.closest(".onebox-result").length === 0 && link.closest('.onebox-body').length === 0) || link.hasClass("track-link"))) { - link.append("" + Discourse.Formatter.number(lc.clicks) + ""); + const $link = $(this), + href = $link.attr('href'); + + let valid = !lc.internal && href === lc.url; + + // this might be an attachment + if (lc.internal) { valid = href.indexOf(lc.url) >= 0; } + + if (valid) { + // don't display badge counts on category badge & oneboxes (unless when explicitely stated) + if ($link.hasClass("track-link") || + $link.closest('.badge-category,.onebox-result,.onebox-body').length === 0) { + $link.append("" + Discourse.Formatter.number(lc.clicks) + ""); } } }); @@ -176,7 +182,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { actions: { // Toggle the replies this post is a reply to - toggleReplyHistory: function(post) { + toggleReplyHistory(post) { var replyHistory = post.get('replyHistory'), topicController = this.get('controller'), @@ -227,7 +233,7 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { }, // Add the quote controls to a post - _insertQuoteControls: function() { + _insertQuoteControls() { var self = this, $quotes = this.$('aside.quote'); diff --git a/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6 index f013611dc7..1598eb1ae9 100644 --- a/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6 +++ b/app/assets/javascripts/discourse/views/topic-footer-buttons.js.es6 @@ -5,23 +5,24 @@ import BookmarkButton from 'discourse/views/bookmark-button'; import ShareButton from 'discourse/views/share-button'; import InviteReplyButton from 'discourse/views/invite-reply-button'; import ReplyButton from 'discourse/views/reply-button'; -import PinnedButton from 'discourse/views/pinned-button'; -import TopicNotificationsButton from 'discourse/views/topic-notifications-button'; +import PinnedButton from 'discourse/components/pinned-button'; +import TopicNotificationsButton from 'discourse/components/topic-notifications-button'; import DiscourseContainerView from 'discourse/views/container'; export default DiscourseContainerView.extend({ elementId: 'topic-footer-buttons', topicBinding: 'controller.content', - init: function() { + init() { this._super(); this.createButtons(); }, // Add the buttons below a topic - createButtons: function() { - var topic = this.get('topic'); + createButtons() { + const topic = this.get('topic'); if (Discourse.User.current()) { + const viewArgs = {topic}; if (Discourse.User.currentProp("staff")) { this.attachViewClass(TopicAdminMenuButton); } @@ -39,8 +40,8 @@ export default DiscourseContainerView.extend({ if (this.get('topic.details.can_create_post')) { this.attachViewClass(ReplyButton); } - this.attachViewClass(PinnedButton); - this.attachViewClass(TopicNotificationsButton); + this.attachViewWithArgs(viewArgs, PinnedButton); + this.attachViewWithArgs(viewArgs, TopicNotificationsButton); this.trigger('additionalButtons', this); } else { diff --git a/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6 b/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6 deleted file mode 100644 index a58ae80dcc..0000000000 --- a/app/assets/javascripts/discourse/views/topic-notifications-button.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import NotificationsButton from 'discourse/views/notifications-button'; - -export default NotificationsButton.extend({ - longDescriptionBinding: 'topic.details.notificationReasonText', - topic: Em.computed.alias('controller.model'), - target: Em.computed.alias('topic'), - hidden: Em.computed.alias('topic.deleted'), - notificationLevels: Discourse.Topic.NotificationLevel, - notificationLevel: Em.computed.alias('topic.details.notification_level'), - isPrivateMessage: Em.computed.alias('topic.isPrivateMessage'), - i18nPrefix: 'topic.notifications', - - i18nPostfix: function() { - return this.get('isPrivateMessage') ? '_pm' : ''; - }.property('isPrivateMessage'), - - clicked: function(id) { - this.get('topic.details').updateNotifications(id); - } -}); diff --git a/app/assets/javascripts/discourse/views/topic.js.es6 b/app/assets/javascripts/discourse/views/topic.js.es6 index 772291a513..1050dc5a32 100644 --- a/app/assets/javascripts/discourse/views/topic.js.es6 +++ b/app/assets/javascripts/discourse/views/topic.js.es6 @@ -51,8 +51,8 @@ var TopicView = Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { var $target = $(e.target); if ($target.hasClass('mention') || $target.parents('.expanded-embed').length) { return false; } - return Discourse.ClickTrack.trackClick(e); + return Discourse.ClickTrack.trackClick(e); }); }.on('didInsertElement'), @@ -126,7 +126,7 @@ var TopicView = Discourse.View.extend(AddCategoryClass, Discourse.Scrolling, { var opts = { latestLink: "" + I18n.t("topic.view_latest_topics") + "" }, category = this.get('controller.content.category'); - if(Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) { + if(category && Em.get(category, 'id') === Discourse.Site.currentProp("uncategorized_category_id")) { category = null; } diff --git a/app/assets/javascripts/discourse/views/user-card.js.es6 b/app/assets/javascripts/discourse/views/user-card.js.es6 index 2cf2801f27..4d009c82c8 100644 --- a/app/assets/javascripts/discourse/views/user-card.js.es6 +++ b/app/assets/javascripts/discourse/views/user-card.js.es6 @@ -6,7 +6,7 @@ var clickOutsideEventName = "mousedown.outside-user-card", export default Discourse.View.extend(CleansUp, { elementId: 'user-card', - classNameBindings: ['controller.visible::hidden', 'controller.showBadges', 'controller.hasCardBadgeImage'], + classNameBindings: ['controller.visible:show', 'controller.showBadges', 'controller.hasCardBadgeImage'], allowBackgrounds: Discourse.computed.setting('allow_profile_backgrounds'), addBackground: function() { diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index 4e2bbb7c55..61ebac3941 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -522,7 +522,7 @@ I18n.enable_verbose_localization = function(){ if (!_.isEmpty(value)) { message += ", parameters: " + JSON.stringify(value); } - //window.console.log(message); + Em.Logger.info(message); } return t.apply(I18n, [scope, value]) + " (t" + current + ")"; }; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 15f6bd8f45..ea57272eae 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -10,6 +10,7 @@ // // Stuff we need to load first +//= require ./discourse/lib/notification-levels //= require ./discourse/lib/app-events //= require ./discourse/helpers/i18n //= require ./discourse/helpers/fa-icon @@ -42,9 +43,9 @@ //= require ./discourse/views/flag //= require ./discourse/views/combo-box //= require ./discourse/views/button -//= require ./discourse/views/dropdown-button -//= require ./discourse/views/notifications-button -//= require ./discourse/views/topic-notifications-button +//= require ./discourse/components/dropdown-button +//= require ./discourse/components/notifications-button +//= require ./discourse/components/topic-notifications-button //= require ./discourse/views/pagedown-preview //= require ./discourse/views/composer //= require ./discourse/routes/discourse_route diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 9ff7cf1f6b..eb9b02fd34 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -1440,3 +1440,9 @@ tr.not-activated { .preview { margin-top: 5px; } + +table#user-badges { + .reason { + max-width: 200px; + } +} diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index a16cadd0af..b86750dbda 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -159,7 +159,7 @@ .badge-category { padding: 4px 10px; display: inline-block; - line-height: 24px; + margin-bottom: 10px; } .category-dropdown-menu .badge-category { diff --git a/app/assets/stylesheets/common/base/about.scss b/app/assets/stylesheets/common/base/about.scss index fdaafacb7a..af5d06a5c1 100644 --- a/app/assets/stylesheets/common/base/about.scss +++ b/app/assets/stylesheets/common/base/about.scss @@ -6,13 +6,43 @@ section.about { } .user-small { - padding: 5px; - width: 200px; + padding: 8px; + width: 205px; + height: 60px; float: left; - img { + .user-image { + float: left; padding-right: 4px; } + + .user-detail { + float: left; + width: 70%; + + span { + float: left; + width: 90%; + padding-left: 5px; + } + + .username a { + font-weight: bold; + font-size: 16px; + color: scale-color($primary, $lightness: 30%); + } + + .name { + font-size: 13px; + color: scale-color($primary, $lightness: 30%); + } + + .title { + font-size: 13px; + color: scale-color($primary, $lightness: 50%); + } + + } } p { diff --git a/app/assets/stylesheets/common/base/magnific-popup.scss b/app/assets/stylesheets/common/base/magnific-popup.scss index 2d18d42d47..0c689265a0 100644 --- a/app/assets/stylesheets/common/base/magnific-popup.scss +++ b/app/assets/stylesheets/common/base/magnific-popup.scss @@ -651,15 +651,15 @@ button { /* start state */ .mfp-content { opacity: 0; - transition: all 0.2s ease-in-out; - -webkit-transform: scale(0.8); - -ms-transform: scale(0.8); - transform: scale(0.8); + transition: all .2s; + -webkit-transform: scale(.8); + -ms-transform: scale(.8); + transform: scale(.8); } &.mfp-bg { opacity: 0; - transition: all 0.3s ease-out; + transition: all .3s ease-out; } /* animate in */ @@ -679,9 +679,9 @@ button { &.mfp-removing { .mfp-content { - -webkit-transform: scale(0.8); - -ms-transform: scale(0.8); - transform: scale(0.8); + -webkit-transform: scale(.8); + -ms-transform: scale(.8); + transform: scale(.8); opacity: 0; } &.mfp-bg { diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 2d36cb8346..588eb44e8a 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -179,7 +179,11 @@ kbd margin: 0 .1em; padding: .1em .6em; - * * { display: none; } + // don't allow more than 3 nested elements to prevent FF from crashing + // cf. http://what.thedailywtf.com/t/nested-elements/7927 + // 3 levels are needed to prevent highlighted words being hidden + // cf. https://meta.discourse.org/t/word-disappears-when-searched-and-in-details-summary-kbd-b/25741 + * * * { display: none; } } // we assume blockquotes have their own margins, so all blockquotes diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index 657aba3d2b..3c8405def3 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -159,3 +159,8 @@ table.badges-listing { text-align: left; } } + +.long-description.banner { + width: 88%; + margin-bottom: 20px; +} diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index e0d9922745..8506cb8917 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -132,12 +132,21 @@ header .title-wrapper .bar .badge-category { .category-breadcrumb li.bar > .badge-category { background: dark-light-diff($primary, $secondary, 95%, -65%) !important; + line-height: 24px; &:not(.home):first-child { border-left-width: 5px; border-left-style: solid; } } +.category-breadcrumb .box > a.badge-category { + margin-bottom: 0; + height: 24px; + // TODO clean this up + padding-top: 6px !important; + padding-bottom: 0 !important; +} + .category-dropdown-menu .cat .badge-wrapper.box { width: 110%; } diff --git a/app/assets/stylesheets/common/components/banner.css.scss b/app/assets/stylesheets/common/components/banner.css.scss index 7047147b8e..f423c7356d 100644 --- a/app/assets/stylesheets/common/components/banner.css.scss +++ b/app/assets/stylesheets/common/components/banner.css.scss @@ -2,7 +2,7 @@ // Banner // -------------------------------------------------- -#banner { +#banner, .banner { padding: 10px; border-radius: 5px; background: scale-color($tertiary, $lightness: 90%); diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss index 2e9ee7e841..6b96e29599 100644 --- a/app/assets/stylesheets/desktop/discourse.scss +++ b/app/assets/stylesheets/desktop/discourse.scss @@ -123,6 +123,9 @@ body { } /* page not found styles */ + h1.page-not-found { + line-height: 30px; + } .page-not-found { margin: 20px 0 40px 0; diff --git a/app/assets/stylesheets/desktop/user-card.scss b/app/assets/stylesheets/desktop/user-card.scss index 5a5703b743..90c5a60913 100644 --- a/app/assets/stylesheets/desktop/user-card.scss +++ b/app/assets/stylesheets/desktop/user-card.scss @@ -5,13 +5,26 @@ width: 500px; left: 20px; z-index: 990; - box-shadow: 0 2px 6px rgba(0,0,0, .6); + box-shadow: 0 2px 6px rgba(0,0,0,.6); margin-top: -2px; background-color: $primary; color: $secondary; background-size: cover; background-position: center center; min-height: 175px; + opacity: 0; + -webkit-transform: scale(.9); + -ms-transform: scale(.9); + transform: scale(.9); + -webkit-transition: opacity .2s, -webkit-transform .2s; + transition: opacity .2s, transform .2s; + + &.show { + opacity: 1; + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + } .card-content { padding: 12px 12px 0 12px; @@ -25,13 +38,13 @@ } } -&.no-bg { - min-height: 50px; + &.no-bg { + min-height: 50px; - .card-content { - margin-top: 0; + .card-content { + margin-top: 0; + } } -} .avatar-placeholder { width: 120px; @@ -84,6 +97,7 @@ color: dark-light-diff($primary, $secondary, 50%, -50%); } } + .groups { font-size: 0.929em; font-weight: normal; diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss index a8b293fb76..22ea3fc3b7 100644 --- a/app/assets/stylesheets/mobile/topic-list.scss +++ b/app/assets/stylesheets/mobile/topic-list.scss @@ -307,5 +307,6 @@ button.dismiss-read { td.main-link { a.title { padding: 5px 10px 5px 0; + font-weight: bold; } } diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 054392acd1..5389b578d5 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -70,14 +70,19 @@ class Admin::GroupsController < Admin::AdminController def add_members group = Group.find(params.require(:id)) - usernames = params.require(:usernames) return can_not_modify_automatic if group.automatic - usernames.split(",").each do |username| - if user = User.find_by_username(username) - group.add(user) - end + if params[:usernames].present? + users = User.where(username: params[:usernames].split(",")) + elsif params[:user_ids].present? + users = User.find(params[:user_ids].split(",")) + else + raise Discourse::InvalidParameters.new('user_ids or usernames must be present') + end + + users.each do |user| + group.add(user) end if group.save @@ -89,14 +94,20 @@ class Admin::GroupsController < Admin::AdminController def remove_member group = Group.find(params.require(:id)) - user_id = params.require(:user_id).to_i return can_not_modify_automatic if group.automatic - user = User.find(user_id) + if params[:user_id].present? + user = User.find(params[:user_id]) + elsif params[:username].present? + user = User.find_by_username(params[:username]) + else + raise Discourse::InvalidParameters.new('user_id or username must be present') + end + user.primary_group_id = nil if user.primary_group_id == group.id - group.users.delete(user_id) + group.users.delete(user.id) if group.save && user.save render json: success_json diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fe60409f75..d36718044c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -59,24 +59,10 @@ class ApplicationController < ActionController::Base use_crawler_layout? ? 'crawler' : 'application' end - rescue_from Exception do |exception| - unless [ActiveRecord::RecordNotFound, - ActionController::RoutingError, - ActionController::UnknownController, - AbstractController::ActionNotFound].include? exception.class - begin - ErrorLog.report_async!(exception, self, request, current_user) - rescue - # dont care give up - end - end - raise - end - # Some exceptions class RenderEmpty < Exception; end - # Render nothing unless we are an xhr request + # Render nothing rescue_from RenderEmpty do render 'default/empty' end @@ -93,40 +79,43 @@ class ApplicationController < ActionController::Base time_left = I18n.t("rate_limiter.hours", count: (e.available_in / 1.hour.to_i)) end - render json: {errors: [I18n.t("rate_limiter.too_many_requests", time_left: time_left)]}, status: 429 + render_json_error I18n.t("rate_limiter.too_many_requests", time_left: time_left), type: :rate_limit, status: 429 end rescue_from Discourse::NotLoggedIn do |e| raise e if Rails.env.test? - if request.get? - redirect_to "/" + if (request.format && request.format.json?) || request.xhr? || !request.get? + rescue_discourse_actions(:not_logged_in, 403, true) else - render status: 403, json: failed_json.merge(message: I18n.t(:not_logged_in)) + redirect_to "/" end end rescue_from Discourse::NotFound do - rescue_discourse_actions("[error: 'not found']", 404) # TODO: this breaks json responses + rescue_discourse_actions(:not_found, 404) end rescue_from Discourse::InvalidAccess do - rescue_discourse_actions("[error: 'invalid access']", 403, true) # TODO: this breaks json responses + rescue_discourse_actions(:invalid_access, 403, true) end rescue_from Discourse::ReadOnly do - render status: 405, json: failed_json.merge(message: I18n.t("read_only_mode_enabled")) + render_json_error I18n.t('read_only_mode_enabled'), type: :read_only, status: 405 end - def rescue_discourse_actions(message, error, include_ember=false) - if request.format && request.format.json? - # TODO: this doesn't make sense. Stuffing an html page into a json response will cause - # $.parseJSON to fail in the browser. Also returning text like "[error: 'invalid access']" - # from the above rescue_from blocks will fail because that isn't valid json. - render status: error, layout: false, text: (error == 404) ? build_not_found_page(error) : message + def rescue_discourse_actions(type, status_code, include_ember=false) + + if (request.format && request.format.json?) || (request.xhr?) + # HACK: do not use render_json_error for topics#show + if request.params[:controller] == 'topics' && request.params[:action] == 'show' + return render status: status_code, layout: false, text: (status_code == 404) ? build_not_found_page(status_code) : I18n.t(type) + end + + render_json_error I18n.t(type), type: type, status: status_code else - render text: build_not_found_page(error, include_ember ? 'application' : 'no_ember') + render text: build_not_found_page(status_code, include_ember ? 'application' : 'no_ember') end end @@ -318,8 +307,17 @@ class ApplicationController < ActionController::Base MultiJson.dump(serializer) end - def render_json_error(obj) - render json: MultiJson.dump(create_errors_json(obj)), status: 422 + # Render action for a JSON error. + # + # obj - a translated string, an ActiveRecord model, or an array of translated strings + # opts: + # type - a machine-readable description of the error + # status - HTTP status code to return + def render_json_error(obj, opts={}) + if opts.is_a? Fixnum + opts = {status: opts} + end + render json: MultiJson.dump(create_errors_json(obj, opts[:type])), status: opts[:status] || 422 end def success_json @@ -377,7 +375,15 @@ class ApplicationController < ActionController::Base # save original URL in a cookie cookies[:destination_url] = request.original_url unless request.original_url =~ /uploads/ - redirect_to :login if SiteSetting.login_required? + + # redirect user to the SSO page if we need to log in AND SSO is enabled + if SiteSetting.login_required? + if SiteSetting.enable_sso? + redirect_to '/session/sso' + else + redirect_to :login + end + end end def block_if_readonly_mode diff --git a/app/controllers/badges_controller.rb b/app/controllers/badges_controller.rb index 77e4a164d0..728a3dbf69 100644 --- a/app/controllers/badges_controller.rb +++ b/app/controllers/badges_controller.rb @@ -17,7 +17,7 @@ class BadgesController < ApplicationController if current_user user_badges = Set.new(current_user.user_badges.select('distinct badge_id').pluck(:badge_id)) end - serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges)) + serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges, include_long_description: true)) respond_to do |format| format.html do store_preloaded "badges", serialized @@ -38,7 +38,7 @@ class BadgesController < ApplicationController end end - serialized = MultiJson.dump(serialize_data(badge, BadgeSerializer, root: "badge")) + serialized = MultiJson.dump(serialize_data(badge, BadgeSerializer, root: "badge", include_long_description: true)) respond_to do |format| format.html do store_preloaded "badge", serialized diff --git a/app/controllers/clicks_controller.rb b/app/controllers/clicks_controller.rb index 056a8e84d7..8472c9e7ff 100644 --- a/app/controllers/clicks_controller.rb +++ b/app/controllers/clicks_controller.rb @@ -8,12 +8,6 @@ class ClicksController < ApplicationController if params[:topic_id].present? || params[:post_id].present? params.merge!({ user_id: current_user.id }) if current_user.present? @redirect_url = TopicLinkClick.create_from(params) - - if @redirect_url.blank? && params[:url].index('?') - # Check the url without query parameters - params[:url].sub!(/\?.*$/, '') - @redirect_url = TopicLinkClick.create_from(params) - end end # Sometimes we want to record a link without a 302. Since XHR has to load the redirected diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 45a2d1aaad..55bdac73f1 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -82,6 +82,7 @@ class ListController < ApplicationController end define_method("category_#{filter}") do + canonical_url "#{Discourse.base_url}#{@category.url}" self.send(filter, { category: @category.id }) end @@ -90,6 +91,7 @@ class ListController < ApplicationController end define_method("parent_category_category_#{filter}") do + canonical_url "#{Discourse.base_url}#{@category.url}" self.send(filter, { category: @category.id }) end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 12c736d1d2..6a1317cb75 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -52,21 +52,24 @@ class SessionController < ApplicationController def sso_login unless SiteSetting.enable_sso - render nothing: true, status: 404 - return + return render(nothing: true, status: 404) end sso = DiscourseSingleSignOn.parse(request.query_string) if !sso.nonce_valid? - render text: I18n.t("sso.timeout_expired"), status: 500 - return + return render(text: I18n.t("sso.timeout_expired"), status: 500) + end + + if ScreenedIpAddress.should_block?(request.remote_ip) + return render(text: I18n.t("sso.unknown_error"), status: 500) end return_path = sso.return_path sso.expire_nonce! begin - if user = sso.lookup_or_create_user + if user = sso.lookup_or_create_user(request.remote_ip) + if SiteSetting.must_approve_users? && !user.approved? render text: I18n.t("sso.account_not_approved"), status: 403 else @@ -92,7 +95,7 @@ class SessionController < ApplicationController SingleSignOn::ACCESSORS.each do |a| details[a] = sso.send(a) end - Discourse.handle_exception(e, details) + Discourse.handle_job_exception(e, details) render text: I18n.t("sso.unknown_error"), status: 500 end @@ -144,9 +147,12 @@ class SessionController < ApplicationController return end - if ScreenedIpAddress.block_login?(user, request.remote_ip) - not_allowed_from_ip_address(user) - return + if ScreenedIpAddress.should_block?(request.remote_ip) + return not_allowed_from_ip_address(user) + end + + if ScreenedIpAddress.block_admin_login?(user, request.remote_ip) + return admin_not_allowed_from_ip_address(user) end (user.active && user.email_confirmed?) ? login(user) : not_activated(user) @@ -226,6 +232,10 @@ class SessionController < ApplicationController render json: {error: I18n.t("login.not_allowed_from_ip_address", username: user.username)} end + def admin_not_allowed_from_ip_address(user) + render json: {error: I18n.t("login.admin_not_allowed_from_ip_address", username: user.username)} + end + def failed_to_login(user) message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended" diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 499fba7bb0..0f76977cc6 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -332,24 +332,15 @@ class TopicsController < ApplicationController guardian.ensure_can_change_post_owner! - post_ids = params[:post_ids].to_a - topic = Topic.find_by(id: params[:topic_id].to_i) - new_user = User.find_by(username: params[:username]) - - return render json: failed_json, status: 422 unless post_ids && topic && new_user - - ActiveRecord::Base.transaction do - post_ids.each do |post_id| - post = Post.find(post_id) - # update topic owner (first avatar) - topic.user = new_user if post.is_first_post? - post.set_owner(new_user, current_user) - end + begin + PostOwnerChanger.new( post_ids: params[:post_ids].to_a, + topic_id: params[:topic_id].to_i, + new_owner: User.find_by(username: params[:username]), + acting_user: current_user ).change_owner! + render json: success_json + rescue ArgumentError + render json: failed_json, status: 422 end - - topic.update_statistics - - render json: success_json end def clear_pin diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index 7ee6555ab2..8ab55b2e26 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -10,7 +10,7 @@ class UserBadgesController < ApplicationController user_badges = user_badges.offset(offset.to_i) end - render_serialized(user_badges, UserBadgeSerializer, root: "user_badges") + render_serialized(user_badges, UserBadgeSerializer, root: "user_badges", include_long_description: true) end def username @@ -25,8 +25,10 @@ class UserBadgesController < ApplicationController end user_badges = user_badges.includes(badge: [:badge_grouping, :badge_type]) + .includes(post: :topic) + .includes(:granted_by) - render_serialized(user_badges, BasicUserBadgeSerializer, root: "user_badges") + render_serialized(user_badges, DetailedUserBadgeSerializer, root: "user_badges") end def create @@ -39,9 +41,22 @@ class UserBadgesController < ApplicationController end badge = fetch_badge_from_params - user_badge = BadgeGranter.grant(badge, user, granted_by: current_user) + post_id = nil - render_serialized(user_badge, UserBadgeSerializer, root: "user_badge") + if params[:reason].present? + path = URI.parse(params[:reason]).path rescue nil + route = Rails.application.routes.recognize_path(path) if path + if route + topic_id = route[:topic_id].to_i + post_number = route[:post_number] || 1 + + post_id = Post.find_by(topic_id: topic_id, post_number: post_number).try(:id) if topic_id > 0 + end + end + + user_badge = BadgeGranter.grant(badge, user, granted_by: current_user, post_id: post_id) + + render_serialized(user_badge, DetailedUserBadgeSerializer, root: "user_badge") end def destroy diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 8c9bf69c57..2e235935b4 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -85,8 +85,10 @@ class Users::OmniauthCallbacksController < ApplicationController user.toggle(:active).save end - if ScreenedIpAddress.block_login?(user, request.remote_ip) + if ScreenedIpAddress.should_block?(request.remote_ip) @data.not_allowed_from_ip_address = true + elsif ScreenedIpAddress.block_admin_login?(user, request.remote_ip) + @data.admin_not_allowed_from_ip_address = true elsif Guardian.new(user).can_access_forum? && user.active # log on any account that is active with forum access log_on_user(user) Invite.invalidate_for_email(user.email) # invite link can't be used to log in anymore diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7d261e048d..5673c3298d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -28,6 +28,9 @@ class UsersController < ApplicationController def show @user = fetch_user_from_params user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user') + if params[:stats].to_s == "false" + user_serializer.omit_stats = true + end respond_to do |format| format.html do @restrict_fields = guardian.restrict_user_fields?(@user) @@ -70,6 +73,7 @@ class UsersController < ApplicationController UserField.where(editable: true).each do |f| val = params[:user_fields][f.id.to_s] val = nil if val === "false" + val = val[0...UserField.max_length] if val return render_json_error(I18n.t("login.missing_user_field")) if val.blank? && f.required? params[:custom_fields]["user_field_#{f.id}"] = val @@ -221,7 +225,7 @@ class UsersController < ApplicationController if field_val.blank? return fail_with("login.missing_user_field") if f.required? else - fields["user_field_#{f.id}"] = field_val + fields["user_field_#{f.id}"] = field_val[0...UserField.max_length] end end @@ -281,7 +285,7 @@ class UsersController < ApplicationController end def password_reset - expires_now() + expires_now if EmailToken.valid_token_format?(params[:token]) @user = EmailToken.confirm(params[:token]) @@ -297,7 +301,7 @@ class UsersController < ApplicationController end if !@user - flash[:error] = I18n.t('password_reset.no_token') + @error = I18n.t('password_reset.no_token') elsif request.put? @invalid_password = params[:password].blank? || params[:password].length > User.max_password_length @@ -325,7 +329,7 @@ class UsersController < ApplicationController 'password_reset.success_unapproved' end - flash[:success] = I18n.t(message) + @success = I18n.t(message) end def change_email diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9bf4db32db..59c8bbb7f5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -137,6 +137,10 @@ module ApplicationHelper end end + def application_logo_url + @application_logo_url ||= (mobile_view? && SiteSetting.mobile_logo_url) || SiteSetting.logo_url + end + def login_path "#{Discourse::base_uri}/login" end @@ -149,9 +153,12 @@ module ApplicationHelper MobileDetection.mobile_device?(request.user_agent) end - def customization_disabled? - controller.class.name.split("::").first == "Admin" || session[:disable_customization] + session[:disable_customization] + end + + def loading_admin? + controller.class.name.split("::").first == "Admin" end def category_badge(category, opts=nil) diff --git a/app/jobs/base.rb b/app/jobs/base.rb index b42b5c79f8..bd9f66c86a 100644 --- a/app/jobs/base.rb +++ b/app/jobs/base.rb @@ -108,7 +108,7 @@ module Jobs begin retval = execute(opts) rescue => exc - Discourse.handle_exception(exc, error_context(opts)) + Discourse.handle_job_exception(exc, error_context(opts)) end return retval end @@ -172,7 +172,7 @@ module Jobs if exceptions.length > 0 exceptions.each do |exception_hash| - Discourse.handle_exception(exception_hash[:ex], + Discourse.handle_job_exception(exception_hash[:ex], error_context(opts, exception_hash[:code], exception_hash[:other])) end raise HandledExceptionWrapper.new exceptions[0][:ex] diff --git a/app/jobs/regular/notify_mailing_list_subscribers.rb b/app/jobs/regular/notify_mailing_list_subscribers.rb index 5ee7897974..b618624b71 100644 --- a/app/jobs/regular/notify_mailing_list_subscribers.rb +++ b/app/jobs/regular/notify_mailing_list_subscribers.rb @@ -36,7 +36,7 @@ module Jobs message = UserNotifications.mailing_list_notify(user, post) Email::Sender.new(message, :mailing_list, user).send rescue => e - Discourse.handle_exception(e, error_context( + Discourse.handle_job_exception(e, error_context( args, "Sending post to mailing list subscribers", { user_id: user.id, diff --git a/app/jobs/regular/resize_emoji.rb b/app/jobs/regular/resize_emoji.rb index 272158e17c..61ae0248c2 100644 --- a/app/jobs/regular/resize_emoji.rb +++ b/app/jobs/regular/resize_emoji.rb @@ -6,8 +6,12 @@ module Jobs path = args[:path] return unless File.exists?(path) + opts = { + allow_animation: true, + force_aspect_ratio: SiteSetting.enforce_square_emoji + } # make sure emoji aren't too big - OptimizedImage.resize(path, path, 60, 60, true) + OptimizedImage.downsize(path, path, 60, 60, opts) end end diff --git a/app/jobs/scheduled/periodical_updates.rb b/app/jobs/scheduled/periodical_updates.rb index 60fdf0aa9a..189ae7bd70 100644 --- a/app/jobs/scheduled/periodical_updates.rb +++ b/app/jobs/scheduled/periodical_updates.rb @@ -22,7 +22,7 @@ module Jobs unless UserAvatar.where("last_gravatar_download_attempt IS NULL").limit(1).first problems = Post.rebake_old(250) problems.each do |hash| - Discourse.handle_exception(hash[:ex], error_context(args, "Rebaking post id #{hash[:post].id}", post_id: hash[:post].id)) + Discourse.handle_job_exception(hash[:ex], error_context(args, "Rebaking post id #{hash[:post].id}", post_id: hash[:post].id)) end end @@ -30,7 +30,7 @@ module Jobs problems = UserProfile.rebake_old(250) problems.each do |hash| user_id = hash[:profile].user_id - Discourse.handle_exception(hash[:ex], error_context(args, "Rebaking user id #{user_id}", user_id: user_id)) + Discourse.handle_job_exception(hash[:ex], error_context(args, "Rebaking user id #{user_id}", user_id: user_id)) end end diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb index 910b936888..6d6f708b61 100644 --- a/app/jobs/scheduled/poll_mailbox.rb +++ b/app/jobs/scheduled/poll_mailbox.rb @@ -74,7 +74,7 @@ module Jobs client_message = RejectionMailer.send_rejection(message_template, message.from, template_args) Email::Sender.new(client_message, message_template).send else - Discourse.handle_exception(e, error_context(@args, "Unrecognized error type when processing incoming email", mail: mail_string)) + Discourse.handle_job_exception(e, error_context(@args, "Unrecognized error type when processing incoming email", mail: mail_string)) end end @@ -91,7 +91,7 @@ module Jobs pop.finish end rescue Net::POPAuthenticationError => e - Discourse.handle_exception(e, error_context(@args, "Signing in to poll incoming email")) + Discourse.handle_job_exception(e, error_context(@args, "Signing in to poll incoming email")) end end diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb index 485fcfd9e6..66c05573fa 100644 --- a/app/models/category_featured_topic.rb +++ b/app/models/category_featured_topic.rb @@ -25,7 +25,7 @@ class CategoryFeaturedTopic < ActiveRecord::Base visible: true, no_definitions: true) - results = query.list_category(c).topic_ids.uniq + results = query.list_category_topic_ids(c).uniq return if results == existing diff --git a/app/models/concerns/limited_edit.rb b/app/models/concerns/limited_edit.rb new file mode 100644 index 0000000000..8e91315bd1 --- /dev/null +++ b/app/models/concerns/limited_edit.rb @@ -0,0 +1,11 @@ +module LimitedEdit + extend ActiveSupport::Concern + + def edit_time_limit_expired? + if created_at && SiteSetting.post_edit_time_limit.to_i > 0 + created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago + else + false + end + end +end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index 92af895200..1c3770d862 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -42,13 +42,13 @@ class DiscourseSingleSignOn < SingleSignOn "SSO_NONCE_#{nonce}" end - def lookup_or_create_user + def lookup_or_create_user(ip_address=nil) sso_record = SingleSignOnRecord.find_by(external_id: external_id) if sso_record && user = sso_record.user sso_record.last_payload = unsigned_payload else - user = match_email_or_create_user + user = match_email_or_create_user(ip_address) sso_record = user.single_sign_on_record end @@ -67,6 +67,7 @@ class DiscourseSingleSignOn < SingleSignOn user.custom_fields[k] = v end + user.ip_address = ip_address user.admin = admin unless admin.nil? user.moderator = moderator unless moderator.nil? @@ -79,16 +80,17 @@ class DiscourseSingleSignOn < SingleSignOn private - def match_email_or_create_user + def match_email_or_create_user(ip_address) user = User.find_by_email(email) try_name = name.blank? ? nil : name try_username = username.blank? ? nil : username user_params = { - email: email, - name: User.suggest_name(try_name || try_username || email), - username: UserNameSuggester.suggest(try_username || try_name || email), + email: email, + name: User.suggest_name(try_name || try_username || email), + username: UserNameSuggester.suggest(try_username || try_name || email), + ip_address: ip_address } if user || user = User.create!(user_params) diff --git a/app/models/discourse_version_check.rb b/app/models/discourse_version_check.rb index 1ac0da47f0..c1be79fcd9 100644 --- a/app/models/discourse_version_check.rb +++ b/app/models/discourse_version_check.rb @@ -1,5 +1,5 @@ class DiscourseVersionCheck include ActiveModel::Model - attr_accessor :latest_version, :critical_updates, :installed_version, :installed_sha, :missing_versions_count, :updated_at, :version_check_pending -end \ No newline at end of file + attr_accessor :latest_version, :critical_updates, :installed_version, :installed_sha, :installed_describe, :missing_versions_count, :updated_at, :version_check_pending +end diff --git a/app/models/error_log.rb b/app/models/error_log.rb deleted file mode 100644 index 8c7944e862..0000000000 --- a/app/models/error_log.rb +++ /dev/null @@ -1,94 +0,0 @@ -# TODO: -# a mechanism to iterate through errors in reverse -# async logging should queue, if dupe stack traces are found in batch error should be merged into prev one - -class ErrorLog - - @lock = Mutex.new - - def self.filename - "#{Rails.root}/log/#{Rails.env}_errors.log" - end - - def self.clear!(_guid) - raise NotImplementedError - end - - def self.clear_all!() - File.delete(ErrorLog.filename) if File.exists?(ErrorLog.filename) - end - - def self.report_async!(exception, controller, request, user) - Thread.new do - report!(exception, controller, request, user) - end - end - - def self.report!(exception, controller, request, user) - add_row!( - date: DateTime.now, - guid: SecureRandom.uuid, - user_id: user && user.id, - parameters: request && request.filtered_parameters.to_json, - action: controller.action_name, - controller: controller.controller_name, - backtrace: sanitize_backtrace(exception.backtrace).join("\n"), - message: exception.message, - url: "#{request.protocol}#{request.env["HTTP_X_FORWARDED_HOST"] || request.env["HTTP_HOST"]}#{request.fullpath}", - exception_class: exception.class.to_s - ) - end - - def self.add_row!(hash) - data = hash.to_xml(skip_instruct: true) - # use background thread to write the log cause it may block if it gets backed up - @lock.synchronize do - File.open(filename, "a") do |f| - f.flock(File::LOCK_EX) - f.write(data) - f.close - end - end - end - - - def self.each(&blk) - skip(0, &blk) - end - - def self.skip(skip=0) - pos = 0 - return [] unless File.exists?(filename) - - loop do - lines = "" - File.open(self.filename, "r") do |f| - f.flock(File::LOCK_SH) - f.pos = pos - while !f.eof? - line = f.readline - lines << line - break if line.starts_with? "" - end - pos = f.pos - end - if lines != "" && skip == 0 - h = {} - e = Nokogiri.parse(lines).children[0] - e.children.each do |inner| - h[inner.name] = inner.text - end - yield h - end - skip-=1 if skip > 0 - break if lines == "" - end - end - - def self.sanitize_backtrace(trace) - re = Regexp.new(/^#{Regexp.escape(Rails.root.to_s)}/) - trace.map { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s } - end - - private_class_method :sanitize_backtrace -end diff --git a/app/models/invite.rb b/app/models/invite.rb index 36194915ed..3ad2910328 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -190,7 +190,7 @@ class Invite < ActiveRecord::Base end def limit_invites_per_day - RateLimiter.new(invited_by, "invites-per-day:#{Date.today}", SiteSetting.max_invites_per_day, 1.day.to_i) + RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i) end def self.base_directory diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 4f1eb56634..1b93d5849f 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -88,8 +88,9 @@ InviteRedeemer = Struct.new(:invite, :username, :name) do end def add_user_to_groups - invite.groups.each do |g| - invited_user.group_users.create(group_id: g.id) + new_group_ids = invite.groups.pluck(:id) - invited_user.group_users.pluck(:group_id) + new_group_ids.each do |id| + invited_user.group_users.create(group_id: id) end end diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index c921d462b0..f736ea4aaa 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -38,7 +38,7 @@ class OptimizedImage < ActiveRecord::Base FileUtils.cp(original_path, temp_path) resized = true else - resized = resize(original_path, temp_path, width, height, opts[:allow_animation]) + resized = resize(original_path, temp_path, width, height, opts) end if resized @@ -81,40 +81,80 @@ class OptimizedImage < ActiveRecord::Base end end - def self.resize(from, to, width, height, allow_animation=false) + def self.resize_instructions(from, to, dimensions, opts={}) # NOTE: ORDER is important! - instructions = if allow_animation && from =~ /\.GIF$/i - %W{ - #{from} - -coalesce - -gravity center - -thumbnail #{width}x#{height}^ - -extent #{width}x#{height} - -layers optimize - #{to} - }.join(" ") - else - %W{ - #{from}[0] - -background transparent - -gravity center - -thumbnail #{width}x#{height}^ - -extent #{width}x#{height} - -interpolate bicubic - -unsharp 2x0.5+0.7+0 - -quality 98 - #{to} - }.join(" ") - end + %W{ + #{from}[0] + -gravity center + -background transparent + -thumbnail #{dimensions}^ + -extent #{dimensions} + -interpolate bicubic + -unsharp 2x0.5+0.7+0 + -quality 98 + #{to} + } + end - `convert #{instructions}` + def self.resize_instructions_animated(from, to, dimensions, opts={}) + %W{ + #{from} + -coalesce + -gravity center + -thumbnail #{dimensions}^ + -extent #{dimensions} + #{to} + } + end - if $?.exitstatus == 0 - ImageOptim.new.optimize_image(to) rescue nil - true - else - false - end + def self.downsize_instructions(from, to, dimensions, opts={}) + %W{ + #{from}[0] + -gravity center + -background transparent + -thumbnail #{dimensions}#{!!opts[:force_aspect_ratio] ? "\\!" : "\\>"} + #{to} + } + end + + def self.downsize_instructions_animated(from, to, dimensions, opts={}) + %W{ + #{from} + -coalesce + -gravity center + -background transparent + -thumbnail #{dimensions}#{!!opts[:force_aspect_ratio] ? "\\!" : "\\>"} + #{to} + } + end + + def self.resize(from, to, width, height, opts={}) + optimize("resize", from, to, width, height, opts) + end + + def self.downsize(from, to, max_width, max_height, opts={}) + optimize("downsize", from, to, max_width, max_height, opts) + end + + def self.optimize(operation, from, to, width, height, opts={}) + dim = dimensions(width, height) + method_name = "#{operation}_instructions" + method_name += "_animated" if !!opts[:allow_animation] && from =~ /\.GIF$/i + instructions = self.send(method_name.to_sym, from, to, dim, opts) + convert_with(instructions) + end + + def self.dimensions(width, height) + "#{width}x#{height}" + end + + def self.convert_with(instructions) + `convert #{instructions.join(" ")}` + + return false if $?.exitstatus != 0 + + ImageOptim.new.optimize_image(to) rescue nil + true end end diff --git a/app/models/post.rb b/app/models/post.rb index 3e4e2433a1..b4bf023d56 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -14,6 +14,7 @@ class Post < ActiveRecord::Base include RateLimiter::OnCreateRecord include Trashable include HasCustomFields + include LimitedEdit # increase this number to force a system wide post rebake BAKED_VERSION = 1 @@ -88,7 +89,7 @@ class Post < ActiveRecord::Base def limit_posts_per_day if user.created_at > 1.day.ago && post_number > 1 - RateLimiter.new(user, "first-day-replies-per-day:#{Date.today}", SiteSetting.max_replies_in_first_day, 1.day.to_i) + RateLimiter.new(user, "first-day-replies-per-day", SiteSetting.max_replies_in_first_day, 1.day.to_i) end end @@ -524,14 +525,6 @@ class Post < ActiveRecord::Base end end - def edit_time_limit_expired? - if created_at && SiteSetting.post_edit_time_limit.to_i > 0 - created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago - else - false - end - end - private def parse_quote_into_arguments(quote) diff --git a/app/models/post_action.rb b/app/models/post_action.rb index cb7cda7ece..03f055a1e2 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -256,6 +256,7 @@ class PostAction < ActiveRecord::Base if post_action post_action.recover! + action_attrs.each { |attr, val| post_action.send("#{attr}=", val) } post_action.save else post_action = create(where_attrs.merge(action_attrs)) @@ -318,7 +319,7 @@ class PostAction < ActiveRecord::Base %w(like flag bookmark).each do |type| if send("is_#{type}?") - @rate_limiter = RateLimiter.new(user, "create_#{type}:#{Date.today}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i) + @rate_limiter = RateLimiter.new(user, "create_#{type}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i) return @rate_limiter end end diff --git a/app/models/screened_ip_address.rb b/app/models/screened_ip_address.rb index d25540a853..3b05ce1e93 100644 --- a/app/models/screened_ip_address.rb +++ b/app/models/screened_ip_address.rb @@ -74,7 +74,7 @@ class ScreenedIpAddress < ActiveRecord::Base found end - def self.block_login?(user, ip_address) + def self.block_admin_login?(user, ip_address) return false if user.nil? return false if !user.admin? return false if ScreenedIpAddress.where(action_type: actions[:allow_admin]).count == 0 diff --git a/app/models/site.rb b/app/models/site.rb index bfe31fb6e7..4d3c159051 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -43,7 +43,12 @@ class Site .secured(@guardian) .includes(:topic_only_relative_url) .order(:position) - .to_a + + unless SiteSetting.allow_uncategorized_topics + categories = categories.where('categories.id <> ?', SiteSetting.uncategorized_category_id) + end + + categories = categories.to_a allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) diff --git a/app/models/topic.rb b/app/models/topic.rb index 4e160b6b3a..4982259bae 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -11,6 +11,7 @@ class Topic < ActiveRecord::Base include RateLimiter::OnCreateRecord include HasCustomFields include Trashable + include LimitedEdit extend Forwardable def_delegator :featured_users, :user_ids, :featured_user_ids @@ -821,7 +822,7 @@ class Topic < ActiveRecord::Base end def apply_per_day_rate_limit_for(key, method_name) - RateLimiter.new(user, "#{key}-per-day:#{Date.today}", SiteSetting.send(method_name), 1.day.to_i) + RateLimiter.new(user, "#{key}-per-day", SiteSetting.send(method_name), 1.day.to_i) end end diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb index e06ad2ce7e..0082635670 100644 --- a/app/models/topic_link_click.rb +++ b/app/models/topic_link_click.rb @@ -1,5 +1,10 @@ require_dependency 'discourse' require 'ipaddr' +require 'url_helper' + +class TopicLinkClickHelper + include UrlHelper +end class TopicLinkClick < ActiveRecord::Base belongs_to :topic_link, counter_cache: :clicks @@ -10,15 +15,27 @@ class TopicLinkClick < ActiveRecord::Base # Create a click from a URL and post_id def self.create_from(args={}) + url = args[:url] + return nil if url.blank? - # If the URL is absolute, allow HTTPS and HTTP versions of it - if args[:url] =~ /^http/ - http_url = args[:url].sub(/^https/, 'http') - https_url = args[:url].sub(/^http\:/, 'https:') - link = TopicLink.select([:id, :user_id]).where('url = ? OR url = ?', http_url, https_url) - else - link = TopicLink.select([:id, :user_id]).where(url: args[:url]) + helper = TopicLinkClickHelper.new + uri = URI.parse(url) rescue nil + + urls = Set.new + urls << url + if url =~ /^http/ + urls << url.sub(/^https/, 'http') + urls << url.sub(/^http:/, 'https:') + urls << helper.schemaless(url) end + urls << helper.absolute_without_cdn(url) + urls << uri.path if uri.try(:host) == Discourse.current_hostname + urls << url.sub(/\?.*$/, '') if url.include?('?') + + link = TopicLink.select([:id, :user_id]) + + # test for all possible URLs + link = link.where(Array.new(urls.count, "url = ?").join(" OR "), *urls) # Find the forum topic link link = link.where(post_id: args[:post_id]) if args[:post_id].present? @@ -27,23 +44,18 @@ class TopicLinkClick < ActiveRecord::Base link = link.where(topic_id: args[:topic_id]) if args[:topic_id].present? link = link.first - # If no link is found, return the url for relative links + # If no link is found... unless link.present? - return args[:url] if args[:url] =~ /^\// + # ... return the url for relative links or when using the same host + return url if url =~ /^\// || uri.try(:host) == Discourse.current_hostname - begin - uri = URI.parse(args[:url]) - return args[:url] if uri.host == URI.parse(Discourse.base_url).host - rescue - end - - # If we have it somewhere else on the site, just allow the redirect. This is - # likely due to a onebox of another topic. - link = TopicLink.find_by(url: args[:url]) + # If we have it somewhere else on the site, just allow the redirect. + # This is likely due to a onebox of another topic. + link = TopicLink.find_by(url: url) return link.present? ? link.url : nil end - return args[:url] if (args[:user_id] && (link.user_id == args[:user_id])) + return url if args[:user_id] && link.user_id == args[:user_id] # Rate limit the click counts to once in 24 hours rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}" @@ -52,7 +64,7 @@ class TopicLinkClick < ActiveRecord::Base create!(topic_link_id: link.id, user_id: args[:user_id], ip_address: args[:ip]) end - args[:url] + url end end diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index 1e6a8bd57b..0fb296171b 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -32,16 +32,7 @@ class TopicList def topics return @topics if @topics.present? - # copy side-loaded data (allowed users) before dumping it with the .to_a - @topics_input.each do |t| - t.allowed_user_ids = if @filter == :private_messages - t.allowed_users.map { |u| u.id }.to_a - else - [] - end - end - - @topics = @topics_input.to_a + @topics = @topics_input # Attach some data for serialization to each topic @topic_lookup = TopicUser.lookup_for(@current_user, @topics) if @current_user @@ -88,11 +79,6 @@ class TopicList @topics end - def topic_ids - return [] unless @topics_input - @topics_input.pluck(:id) - end - def attributes {'more_topics_url' => page} end diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index d0255e1b90..2611039f31 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -28,7 +28,8 @@ class TopicUser < ActiveRecord::Base :auto_watch, :auto_watch_category, :auto_mute_category, - :auto_track_category + :auto_track_category, + :plugin_changed ) end diff --git a/app/models/user_field.rb b/app/models/user_field.rb index 7ca0c997e9..648163326a 100644 --- a/app/models/user_field.rb +++ b/app/models/user_field.rb @@ -1,5 +1,9 @@ class UserField < ActiveRecord::Base validates_presence_of :name, :description, :field_type + + def self.max_length + 2048 + end end # == Schema Information diff --git a/app/serializers/about_serializer.rb b/app/serializers/about_serializer.rb index 63d795499e..df8eebc177 100644 --- a/app/serializers/about_serializer.rb +++ b/app/serializers/about_serializer.rb @@ -1,6 +1,6 @@ class AboutSerializer < ApplicationSerializer - has_many :moderators, serializer: BasicUserSerializer, embed: :objects - has_many :admins, serializer: BasicUserSerializer, embed: :objects + has_many :moderators, serializer: UserNameSerializer, embed: :objects + has_many :admins, serializer: UserNameSerializer, embed: :objects attributes :stats, :description, diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb index 0ac048ae2d..415404d5cd 100644 --- a/app/serializers/badge_serializer.rb +++ b/app/serializers/badge_serializer.rb @@ -1,10 +1,28 @@ class BadgeSerializer < ApplicationSerializer attributes :id, :name, :description, :grant_count, :allow_title, :multiple_grant, :icon, :image, :listable, :enabled, :badge_grouping_id, - :system + :system, :long_description + has_one :badge_type def system object.system? end + + def include_long_description? + options[:include_long_description] + end + + def long_description + if object.long_description.present? + object.long_description + else + key = "badges.long_descriptions.#{object.name.downcase.gsub(" ", "_")}" + if I18n.exists?(key) + I18n.t(key) + else + "" + end + end + end end diff --git a/app/serializers/detailed_user_badge_serializer.rb b/app/serializers/detailed_user_badge_serializer.rb new file mode 100644 index 0000000000..1fa408844d --- /dev/null +++ b/app/serializers/detailed_user_badge_serializer.rb @@ -0,0 +1,26 @@ +class DetailedUserBadgeSerializer < BasicUserBadgeSerializer + has_one :granted_by + + attributes :post_number, :topic_id, :topic_title + + def include_post_number? + object.post + end + + alias :include_topic_id? :include_post_number? + alias :include_topic_title? :include_post_number? + + + def post_number + object.post.post_number if object.post + end + + def topic_id + object.post.topic_id if object.post + end + + def topic_title + object.post.topic.title if object.post && object.post.topic + end + +end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 0565a1a003..d7630a96d5 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -112,9 +112,11 @@ class PostRevisionSerializer < ApplicationSerializer end def title_changes - prev = "
#{CGI::escapeHTML(previous["title"])}
" - cur = "
#{CGI::escapeHTML(current["title"])}
" - return if prev == cur + prev = "
#{previous["title"] && CGI::escapeHTML(previous["title"])}
" + cur = "
#{current["title"] && CGI::escapeHTML(current["title"])}
" + + # always show the title for post_number == 1 + return if object.post.post_number > 1 && prev == cur diff = DiscourseDiff.new(prev, cur) diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 3d2149e8ef..38c30737b2 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -10,7 +10,8 @@ class SiteSerializer < ApplicationSerializer :anonymous_top_menu_items, :uncategorized_category_id, # this is hidden so putting it here :is_readonly, - :disabled_plugins + :disabled_plugins, + :user_field_max_length has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :post_action_types, embed: :objects @@ -19,7 +20,6 @@ class SiteSerializer < ApplicationSerializer has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serialzer: UserFieldSerializer - def default_archetype Archetype.default end @@ -56,4 +56,8 @@ class SiteSerializer < ApplicationSerializer Discourse.disabled_plugin_names end + def user_field_max_length + UserField.max_length + end + end diff --git a/app/serializers/user_name_serializer.rb b/app/serializers/user_name_serializer.rb new file mode 100644 index 0000000000..ac7beaa8d2 --- /dev/null +++ b/app/serializers/user_name_serializer.rb @@ -0,0 +1,20 @@ +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 + +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 6cc710935f..f589b329fe 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,5 +1,7 @@ class UserSerializer < BasicUserSerializer + attr_accessor :omit_stats + def self.staff_attributes(*attrs) attributes(*attrs) attrs.each do |attr| @@ -171,6 +173,10 @@ class UserSerializer < BasicUserSerializer scope.can_edit_name?(object) end + def include_stats? + !omit_stats == true + end + def stats UserAction.stats(object.id, scope) end @@ -246,6 +252,10 @@ class UserSerializer < BasicUserSerializer CategoryUser.lookup(object, :watching).pluck(:category_id) end + def include_private_message_stats? + can_edit && !(omit_stats == true) + end + def private_messages_stats UserAction.private_messages_stats(object.id, scope) end diff --git a/app/services/post_owner_changer.rb b/app/services/post_owner_changer.rb new file mode 100644 index 0000000000..bf1311d0f6 --- /dev/null +++ b/app/services/post_owner_changer.rb @@ -0,0 +1,23 @@ +class PostOwnerChanger + + def initialize(params) + @post_ids = params[:post_ids] + @topic = Topic.find_by(id: params[:topic_id].to_i) + @new_owner = params[:new_owner] + @acting_user = params[:acting_user] + + raise ArgumentError unless @post_ids && @topic && @new_owner && @acting_user + end + + def change_owner! + ActiveRecord::Base.transaction do + @post_ids.each do |post_id| + post = Post.find(post_id) + @topic.user = @new_owner if post.is_first_post? + post.set_owner(@new_owner, @acting_user) + end + end + + @topic.update_statistics + end +end diff --git a/app/services/random_topic_selector.rb b/app/services/random_topic_selector.rb new file mode 100644 index 0000000000..7c2026b5ef --- /dev/null +++ b/app/services/random_topic_selector.rb @@ -0,0 +1,82 @@ +class RandomTopicSelector + + BACKFILL_SIZE = 3000 + BACKFILL_LOW_WATER_MARK = 500 + + def self.backfill(category=nil) + + exclude = category.try(:topic_id) + + # don't leak private categories into the "everything" group + user = category ? CategoryFeaturedTopic.fake_admin : nil + + options = { + per_page: SiteSetting.category_featured_topics, + visible: true, + no_definitions: true + } + + options[:except_topic_ids] = [category.topic_id] if exclude + options[:category] = category.id if category + + query = TopicQuery.new(user, options) + results = query.latest_results.order('RANDOM()') + .where(closed: false, archived: false) + .limit(BACKFILL_SIZE) + .reorder('RANDOM()') + .pluck(:id) + + key = cache_key(category) + results.each do |id| + $redis.rpush(key, id) + end + $redis.expire(key, 2.days) + + results + end + + def self.next(count, category=nil) + key = cache_key(category) + + results = [] + + left = count + + while left > 0 + id = $redis.lpop key + break unless id + + results << id.to_i + left -= 1 + end + + backfilled = false + if left > 0 + ids = backfill(category) + backfilled = true + results += ids[0...count] + results.uniq! + results = results[0...count] + end + + if !backfilled && $redis.llen(key) < BACKFILL_LOW_WATER_MARK + Scheduler::Defer.later("backfill") do + backfill(category) + end + end + + results + end + + def self.clear_cache! + Category.select(:id).each do |c| + $redis.del cache_key(c) + end + $redis.del cache_key + end + + def self.cache_key(category=nil) + "random_topic_cache_#{category.try(:id)}" + end + +end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 10b13092a2..0e420e5f4a 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -100,7 +100,17 @@ class StaffActionLogger })) end - SITE_CUSTOMIZATION_LOGGED_ATTRS = ['stylesheet', 'header', 'position', 'enabled', 'key'] + SITE_CUSTOMIZATION_LOGGED_ATTRS = [ + 'stylesheet', 'mobile_stylesheet', + 'header', 'mobile_header', + 'top', 'mobile_top', + 'footer', 'mobile_footer', + 'head_tag', + 'body_tag', + 'position', + 'enabled', + 'key' + ] def log_site_customization_change(old_record, site_customization_params, opts={}) raise Discourse::InvalidParameters.new(:site_customization_params) unless site_customization_params diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 74a785c4b9..8fd14c2362 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -6,17 +6,17 @@ class UserUpdater muted_category_ids: :muted } - USER_ATTR = [ - :email_digests, - :email_always, - :email_direct, - :email_private_messages, - :external_links_in_new_tab, - :enable_quoting, - :dynamic_favicon, - :mailing_list_mode, - :disable_jump_reply, - :edit_history_public + USER_ATTR = [ + :email_digests, + :email_always, + :email_direct, + :email_private_messages, + :external_links_in_new_tab, + :enable_quoting, + :dynamic_favicon, + :mailing_list_mode, + :disable_jump_reply, + :edit_history_public ] PROFILE_ATTR = [ diff --git a/app/views/application/_header.html.erb b/app/views/application/_header.html.erb new file mode 100644 index 0000000000..d0f3c8b39f --- /dev/null +++ b/app/views/application/_header.html.erb @@ -0,0 +1,22 @@ +
+ +
diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb index a72accad15..737ff157e6 100644 --- a/app/views/common/_discourse_javascript.html.erb +++ b/app/views/common/_discourse_javascript.html.erb @@ -9,7 +9,6 @@ <%= script 'browser-update' %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f4c61ef136..baf1f7cbd0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -37,17 +37,7 @@