diff --git a/.travis.yml b/.travis.yml index 70be408153..6692c0e3e0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -47,6 +47,7 @@ before_install: - git clone --depth=1 https://github.com/discourse/discourse-chat-integration.git plugins/discourse-chat-integration - git clone --depth=1 https://github.com/discourse/discourse-assign.git plugins/discourse-assign - git clone --depth=1 https://github.com/discourse/discourse-patreon.git plugins/discourse-patreon + - git clone --depth=1 https://github.com/discourse/discourse-staff-notes.git plugins/discourse-staff-notes - export PATH=$HOME/.yarn/bin:$PATH install: diff --git a/Gemfile b/Gemfile index 2f4e597291..0b3f89a6ad 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.45' +gem 'onebox', '1.8.46' gem 'http_accept_language', '~>2.0.5', require: false @@ -116,7 +116,6 @@ group :test, :development do gem 'certified', require: false # later appears to break Fabricate(:topic, category: category) gem 'fabrication', '2.9.8', require: false - gem 'discourse-qunit-rails', require: 'qunit-rails' gem 'mocha', require: false gem 'rb-fsevent', require: RUBY_PLATFORM =~ /darwin/i ? 'rb-fsevent' : false gem 'rb-inotify', '~> 0.9', require: RUBY_PLATFORM =~ /linux/i ? 'rb-inotify' : false diff --git a/Gemfile.lock b/Gemfile.lock index 0d7600cb2b..2ff7ea8552 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,8 +83,6 @@ GEM crass (1.0.3) debug_inspector (0.0.3) diff-lcs (1.3) - discourse-qunit-rails (0.0.11) - railties discourse_image_optim (0.24.5) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) @@ -166,7 +164,7 @@ GEM mail (2.7.0) mini_mime (>= 0.1.1) memory_profiler (0.9.10) - message_bus (2.1.2) + message_bus (2.1.4) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) @@ -226,7 +224,7 @@ GEM omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.45) + onebox (1.8.46) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -289,9 +287,9 @@ GEM ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) - redis (3.3.5) - redis-namespace (1.5.3) - redis (~> 3.0, >= 3.0.4) + redis (4.0.1) + redis-namespace (1.6.0) + redis (>= 3.0.4) request_store (1.3.2) rinku (2.0.2) rotp (3.3.0) @@ -355,11 +353,11 @@ GEM shoulda-context (1.2.2) shoulda-matchers (2.8.0) activesupport (>= 3.0.0) - sidekiq (5.0.5) + sidekiq (5.1.3) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) - redis (>= 3.3.4, < 5) + redis (>= 3.3.5, < 5) slop (3.6.0) sprockets (3.7.1) concurrent-ruby (~> 1.0) @@ -412,7 +410,6 @@ DEPENDENCIES byebug certified cppjieba_rb - discourse-qunit-rails discourse_image_optim email_reply_trimmer (= 0.1.11) ember-handlebars-template (= 0.7.5) @@ -460,7 +457,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.45) + onebox (= 1.8.46) openid-redis-store pg (~> 0.21.0) pry-nav diff --git a/README.md b/README.md index 51b8314318..6e04e399e9 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The original Discourse code contributors can be found in [**AUTHORS.MD**](docs/A ## Copyright / License -Copyright 2014 - 2017 Civilized Discourse Construction Kit, Inc. +Copyright 2014 - 2018 Civilized Discourse Construction Kit, Inc. Licensed under the GNU General Public License Version 2.0 (or later); you may not use this work except in compliance with the License. diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 new file mode 100644 index 0000000000..070625e9e4 --- /dev/null +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -0,0 +1,65 @@ +import { ajax } from 'discourse/lib/ajax'; +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ["dashboard-table", "dashboard-inline-table"], + + classNameBindings: ["isLoading"], + + total: null, + labels: null, + title: null, + chartData: null, + isLoading: false, + help: null, + helpPage: null, + model: null, + + didInsertElement() { + this._super(); + + if (this.get("dataSourceName")){ + this._fetchReport(); + } else if (this.get("model")) { + this._setPropertiesFromModel(this.get("model")); + } + }, + + didUpdateAttrs() { + this._super(); + + if (this.get("model")) { + this._setPropertiesFromModel(this.get("model")); + } + }, + + @computed("dataSourceName") + dataSource(dataSourceName) { + return `/admin/reports/${dataSourceName}`; + }, + + _fetchReport() { + if (this.get("isLoading")) return; + + this.set("isLoading", true); + + ajax(this.get("dataSource")) + .then((response) => { + this._setPropertiesFromModel(response.report); + }).finally(() => { + this.set("isLoading", false); + }); + }, + + _setPropertiesFromModel(model) { + const data = model.data.sort((a, b) => a.x >= b.x); + + this.setProperties({ + labels: data.map(r => r.x), + dataset: data.map(r => r.y), + total: model.total, + title: model.title, + chartData: data + }); + } +}); diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 new file mode 100644 index 0000000000..eea4bc917c --- /dev/null +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -0,0 +1,171 @@ +import { ajax } from 'discourse/lib/ajax'; +import computed from 'ember-addons/ember-computed-decorators'; +import loadScript from 'discourse/lib/load-script'; + +export default Ember.Component.extend({ + classNames: ["dashboard-mini-chart"], + + classNameBindings: ["trend", "oneDataPoint", "isLoading"], + + isLoading: false, + total: null, + trend: null, + title: null, + oneDataPoint: false, + backgroundColor: "rgba(200,220,240,0.3)", + borderColor: "#08C", + + didInsertElement() { + this._super(); + this._initializeChart(); + }, + + didUpdateAttrs() { + this._super(); + this._initializeChart(); + }, + + @computed("dataSourceName") + dataSource(dataSourceName) { + if (dataSourceName) { + return `/admin/reports/${dataSourceName}`; + } + }, + + @computed("trend") + trendIcon(trend) { + if (trend === "stable") { + return null; + } else { + return `angle-${trend}`; + } + }, + + _fetchReport() { + if (this.get("isLoading")) return; + + this.set("isLoading", true); + + let payload = {data: {}}; + + if (this.get("startDate")) { + payload.data.start_date = this.get("startDate").toISOString(); + } + + if (this.get("endDate")) { + payload.data.end_date = this.get("endDate").toISOString(); + } + + ajax(this.get("dataSource"), payload) + .then((response) => { + this._setPropertiesFromModel(response.report); + }) + .finally(() => { + this.set("isLoading", false); + + Ember.run.schedule("afterRender", () => { + if (!this.get("oneDataPoint")) { + this._drawChart(); + } + }); + }); + }, + + _initializeChart() { + loadScript("/javascripts/Chart.min.js").then(() => { + if (this.get("model") && !this.get("values")) { + this._setPropertiesFromModel(this.get("model")); + this._drawChart(); + } else if (this.get("dataSource")) { + this._fetchReport(); + } + }); + }, + + _drawChart() { + const $chartCanvas = this.$(".chart-canvas"); + if (!$chartCanvas.length) return; + + const context = $chartCanvas[0].getContext("2d"); + + const data = { + labels: this.get("labels"), + datasets: [{ + data: this.get("values"), + backgroundColor: this.get("backgroundColor"), + borderColor: this.get("borderColor") + }] + }; + + this._chart = new window.Chart(context, this._buildChartConfig(data)); + }, + + _setPropertiesFromModel(model) { + this.setProperties({ + labels: model.data.map(r => r.x), + values: model.data.map(r => r.y), + oneDataPoint: (this.get("startDate") && this.get("endDate")) && + this.get("startDate").isSame(this.get("endDate"), 'day'), + total: model.total, + title: model.title, + trend: this._computeTrend(model.total, model.prev30Days) + }); + }, + + _buildChartConfig(data) { + const values = this.get("values"); + const max = Math.max(...values); + const min = Math.min(...values); + const stepSize = Math.max(...[Math.ceil((max - min)/5), 20]); + + const startDate = this.get("startDate") || moment(); + const endDate = this.get("endDate") || moment(); + const datesDifference = startDate.diff(endDate, "days"); + let unit = "day"; + if (datesDifference >= 366) { + unit = "quarter"; + } else if (datesDifference >= 61) { + unit = "month"; + } else if (datesDifference >= 14) { + unit = "week"; + } + + return { + type: "line", + data, + options: { + legend: { display: false }, + responsive: true, + layout: { padding: { left: 0, top: 0, right: 0, bottom: 0 } }, + scales: { + yAxes: [ + { + display: true, + ticks: { suggestedMin: 0, stepSize, suggestedMax: max + stepSize } + } + ], + xAxes: [ + { + display: true, + type: "time", + time: { + parser: "YYYY-MM-DD", + unit + } + } + ], + } + }, + }; + }, + + _computeTrend(total, prevTotal) { + const percentChange = ((total - prevTotal) / prevTotal) * 100; + + if (percentChange > 50) return "double-up"; + if (percentChange > 0) return "up"; + if (percentChange === 0) return "stable"; + if (percentChange < 50) return "double-down"; + if (percentChange < 0) return "down"; + }, +}); diff --git a/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 b/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 new file mode 100644 index 0000000000..aac53bb9c0 --- /dev/null +++ b/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 @@ -0,0 +1,17 @@ +import DashboardTable from "admin/components/dashboard-table"; +import { number } from 'discourse/lib/formatter'; + +export default DashboardTable.extend({ + layoutName: "admin/templates/components/dashboard-table", + + classNames: ["dashboard-table", "dashboard-table-trending-search"], + + transformModel(model) { + return { + labels: model.labels, + values: model.data.map(data => { + return [data[0], number(data[1]), number(data[2])]; + }) + }; + }, +}); diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6 new file mode 100644 index 0000000000..2bf5929443 --- /dev/null +++ b/app/assets/javascripts/admin/components/dashboard-table.js.es6 @@ -0,0 +1,83 @@ +import { ajax } from 'discourse/lib/ajax'; +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ["dashboard-table"], + + classNameBindings: ["isLoading"], + + total: null, + labels: null, + title: null, + chartData: null, + isLoading: false, + help: null, + helpPage: null, + model: null, + + transformModel(model) { + const data = model.data.sort((a, b) => a.x >= b.x); + + return { + labels: model.labels, + values: data + }; + }, + + didInsertElement() { + this._super(); + this._initializeTable(); + }, + + didUpdateAttrs() { + this._super(); + this._initializeTable(); + }, + + @computed("dataSourceName") + dataSource(dataSourceName) { + return `/admin/reports/${dataSourceName}`; + }, + + _initializeTable() { + if (this.get("model") && !this.get("values")) { + this._setPropertiesFromModel(this.get("model")); + } else if (this.get("dataSource")) { + this._fetchReport(); + } + }, + + _fetchReport() { + if (this.get("isLoading")) return; + + this.set("isLoading", true); + + let payload = {data: {}}; + + if (this.get("startDate")) { + payload.data.start_date = this.get("startDate").toISOString(); + } + + if (this.get("endDate")) { + payload.data.end_date = this.get("endDate").toISOString(); + } + + ajax(this.get("dataSource"), payload) + .then((response) => { + this._setPropertiesFromModel(response.report); + }).finally(() => { + this.set("isLoading", false); + }); + }, + + _setPropertiesFromModel(model) { + const { labels, values } = this.transformModel(model); + + this.setProperties({ + labels, + values, + total: model.total, + title: model.title + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 new file mode 100644 index 0000000000..3afb55343d --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 @@ -0,0 +1,87 @@ +import DiscourseURL from "discourse/lib/url"; +import computed from "ember-addons/ember-computed-decorators"; +import AdminDashboardNext from 'admin/models/admin-dashboard-next'; +import Report from 'admin/models/report'; + +const ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at"]; + +const REPORTS = [ "global_reports", "user_reports" ]; + +export default Ember.Controller.extend({ + queryParams: ["period"], + period: "all", + isLoading: false, + dashboardFetchedAt: null, + exceptionController: Ember.inject.controller('exception'), + + fetchDashboard() { + if (this.get("isLoading")) return; + + if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) { + this.set("isLoading", true); + + AdminDashboardNext.find().then(d => { + this.set("dashboardFetchedAt", new Date()); + + const reports = {}; + REPORTS.forEach(name => d[name].forEach(r => reports[`${name}_${r.type}`] = Report.create(r))); + this.setProperties(reports); + + ATTRIBUTES.forEach(a => this.set(a, d[a])); + }).catch(e => { + this.get("exceptionController").set("thrown", e.jqXHR); + this.replaceRoute("exception"); + }).finally(() => { + this.set("isLoading", false); + }); + } + }, + + @computed("period") + startDate(period) { + switch (period) { + case "yearly": + return moment().subtract(1, "year").startOf("day"); + break; + case "quarterly": + return moment().subtract(3, "month").startOf("day"); + break; + case "weekly": + return moment().subtract(1, "week").startOf("day"); + break; + case "monthly": + return moment().subtract(1, "month").startOf("day"); + break; + case "daily": + return moment().startOf("day"); + break; + default: + return null; + } + }, + + @computed("period") + endDate(period) { + return period === "all" ? null : moment().endOf("day"); + }, + + @computed("updated_at") + updatedTimestamp(updatedAt) { + return moment(updatedAt).format("LLL"); + }, + + @computed("last_backup_taken_at") + backupTimestamp(lastBackupTakenAt) { + return moment(lastBackupTakenAt).format("LLL"); + }, + + actions: { + changePeriod(period) { + DiscourseURL.routeTo(this._reportsForPeriodURL(period)); + } + }, + + _reportsForPeriodURL(period) { + return `/admin/dashboard-next?period=${period}`; + } +}); diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 new file mode 100644 index 0000000000..fb4d7519c3 --- /dev/null +++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 @@ -0,0 +1,23 @@ +import { ajax } from 'discourse/lib/ajax'; + +const AdminDashboardNext = Discourse.Model.extend({}); + +AdminDashboardNext.reopenClass({ + + /** + Fetch all dashboard data. This can be an expensive request when the cached data + has expired and the server must collect the data again. + + @method find + @return {jqXHR} a jQuery Promise object + **/ + find: function() { + return ajax("/admin/dashboard-next.json").then(function(json) { + var model = AdminDashboardNext.create(json); + model.set('loaded', true); + return model; + }); + }, +}); + +export default AdminDashboardNext; diff --git a/app/assets/javascripts/admin/models/flagged-post.js.es6 b/app/assets/javascripts/admin/models/flagged-post.js.es6 index c33f3d3c49..a1cd66ccd9 100644 --- a/app/assets/javascripts/admin/models/flagged-post.js.es6 +++ b/app/assets/javascripts/admin/models/flagged-post.js.es6 @@ -62,11 +62,74 @@ export default Post.extend({ }, deferFlags(deletePost) { - return ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } }).catch(popupAjaxError); + const action = () => { + return ajax('/admin/flags/defer/' + this.id, { + type: 'POST', cache: false, data: { delete_post: deletePost } + }); + }; + + if (deletePost && this._hasDeletableReplies()) { + return this._actOnFlagAndDeleteReplies(action); + } else { + return action().catch(popupAjaxError); + } }, agreeFlags(actionOnPost) { - return ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { action_on_post: actionOnPost } }).catch(popupAjaxError); + const action = () => { + return ajax('/admin/flags/agree/' + this.id, { + type: 'POST', cache: false, data: { action_on_post: actionOnPost } + }); + }; + + if (actionOnPost === 'delete' && this._hasDeletableReplies()) { + return this._actOnFlagAndDeleteReplies(action); + } else { + return action().catch(popupAjaxError); + } + }, + + _hasDeletableReplies() { + return this.get('post_number') > 1 && this.get('reply_count') > 0; + }, + + _actOnFlagAndDeleteReplies(action) { + return new Ember.RSVP.Promise((resolve, reject) => { + return ajax(`/posts/${this.id}/reply-ids/all.json`).then(replies => { + const buttons = []; + + buttons.push({ + label: I18n.t('no_value'), + callback() { + action() + .then(resolve) + .catch(error => { + popupAjaxError(error); + reject(); + }); + } + }); + + buttons.push({ + label: I18n.t('yes_value'), + class: "btn-danger", + callback() { + Post.deleteMany(replies.map(r => r.id)) + .then(action) + .then(resolve) + .catch(error => { + popupAjaxError(error); + reject(); + }); + } + }); + + bootbox.dialog(I18n.t("admin.flags.delete_replies", { count: replies.length }), buttons); + }).catch(error => { + popupAjaxError(error); + reject(); + }); + }); }, postHidden: Ember.computed.alias('hidden'), diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 new file mode 100644 index 0000000000..30ca9b033c --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + activate() { + this.controllerFor('admin-dashboard-next').fetchDashboard(); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index b4926b9a5a..5b42f045e1 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,6 +1,7 @@ export default function() { this.route('admin', { resetNamespace: true }, function() { this.route('dashboard', { path: '/' }); + this.route('dashboardNext', { path: '/dashboard-next' }); this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() { this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} ); }); diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs new file mode 100644 index 0000000000..b7b0bdc61e --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs @@ -0,0 +1,28 @@ +{{#conditional-loading-spinner condition=isLoading}} +
+

{{title}}

+ + {{#if help}} + {{i18n help}} + {{/if}} +
+ +
+ + + + {{#each labels as |label|}} + + {{/each}} + + + + + {{#each dataset as |data|}} + + {{/each}} + + +
{{label}}
{{number data}}
+
+{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs new file mode 100644 index 0000000000..7f12611bc9 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs @@ -0,0 +1,26 @@ +{{#conditional-loading-spinner condition=isLoading}} +
+

{{title}}

+ + {{#if help}} + {{d-icon "question-circle" title=help}} + {{/if}} +
+ +
+ {{#if oneDataPoint}} + + {{number chartData.lastObject.y}} + + {{else}} +
+ {{number total}} + + {{#if trendIcon}} + {{d-icon trendIcon}} + {{/if}} +
+ + {{/if}} +
+{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs new file mode 100644 index 0000000000..cb4070e40e --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs @@ -0,0 +1,31 @@ +{{#conditional-loading-spinner condition=isLoading}} +
+

{{title}}

+ + {{#if help}} + {{i18n help}} + {{/if}} +
+ +
+ + + + {{#each labels as |label|}} + + {{/each}} + + + + + {{#each values as |value|}} + + {{#each value as |v|}} + + {{/each}} + + {{/each}} + +
{{label}}
{{v}}
+
+{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs new file mode 100644 index 0000000000..99f3de35bd --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -0,0 +1,82 @@ +{{plugin-outlet name="admin-dashboard-top"}} +{{lastRefreshedAt}} +
+
+

{{i18n "admin.dashboard.community_health"}}

+ {{period-chooser period=period action="changePeriod"}} +
+ +
+
+ {{dashboard-mini-chart + model=global_reports_signups + dataSourceName="signups" + startDate=startDate + endDate=endDate + help="admin.dashboard.charts.signups.help"}} + + {{dashboard-mini-chart + model=global_reports_topics + dataSourceName="topics" + startDate=startDate + endDate=endDate + help="admin.dashboard.charts.topics.help"}} +
+
+
+ +
+
+ {{dashboard-inline-table + model=user_reports_users_by_type + lastRefreshedAt=lastRefreshedAt + isLoading=isLoading}} + + {{dashboard-inline-table + model=user_reports_users_by_trust_level + lastRefreshedAt=lastRefreshedAt + isLoading=isLoading}} + + {{#conditional-loading-spinner isLoading=isLoading}} +
+
+ {{#if currentUser.admin}} +
+

{{i18n "admin.dashboard.backups"}}

+

+ {{disk_space.backups_used}} ({{i18n "admin.dashboard.space_free" size=disk_space.backups_free}}) +
+ {{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}} +

+
+ {{/if}} + +
+

{{i18n "admin.dashboard.uploads"}}

+

+ {{disk_space.uploads_used}} ({{i18n "admin.dashboard.space_free" size=disk_space.uploads_free}}) +

+
+
+ +
+ +

+ {{i18n "admin.dashboard.last_updated"}} {{updatedTimestamp}} +

+ + + {{i18n "admin.dashboard.whats_new_in_discourse"}} + +
+ {{/conditional-loading-spinner}} +
+ +
+ {{dashboard-table-trending-search + model=global_reports_trending_search + dataSourceName="trending_search" + startDate=startDate + endDate=endDate}} +
+
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 5b2f654383..a1888006db 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -64,6 +64,7 @@ //= require ./discourse/models/draft //= require ./discourse/models/composer //= require ./discourse/models/user-badge +//= require_tree ./discourse/lib //= require_tree ./discourse/mixins //= require ./discourse/models/invite //= require ./discourse/controllers/discovery-sortable @@ -87,7 +88,6 @@ //= require ./discourse/helpers/loading-spinner //= require ./discourse/helpers/category-link //= require ./discourse/lib/export-result -//= require_tree ./discourse/lib //= require ./discourse/mapping-router //= require_tree ./discourse/controllers diff --git a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 index aa3f057e00..1a6c453f41 100644 --- a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 @@ -9,7 +9,7 @@ const REPLACEMENTS = { 'd-watching-first': 'dot-circle-o', 'd-drop-expanded': 'caret-down', 'd-drop-collapsed': 'caret-right', - 'd-unliked': 'heart', + 'd-unliked': 'heart-o', 'd-liked': 'heart', 'notification.mentioned': "at", 'notification.group_mentioned': "at", diff --git a/app/assets/javascripts/discourse/components/create-topic-button.js.es6 b/app/assets/javascripts/discourse/components/create-topic-button.js.es6 index a792b83a29..c450bf7206 100644 --- a/app/assets/javascripts/discourse/components/create-topic-button.js.es6 +++ b/app/assets/javascripts/discourse/components/create-topic-button.js.es6 @@ -1 +1,4 @@ -export default Ember.Component.extend({ tagName: '' }); +export default Ember.Component.extend({ + tagName: '', + label: 'topic.create' +}); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index c2811e5e5a..26d48d9b58 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -12,6 +12,7 @@ import { siteDir } from 'discourse/lib/text-direction'; import { determinePostReplaceSelection, clipboardData } from 'discourse/lib/utilities'; import toMarkdown from 'discourse/lib/to-markdown'; import deprecated from 'discourse-common/lib/deprecated'; +import { wantsNewWindow } from 'discourse/lib/intercept-click'; // Our head can be a static string or a function that returns a string // based on input (like for numbered lists). @@ -258,7 +259,15 @@ export default Ember.Component.extend({ // disable clicking on links in the preview this.$('.d-editor-preview').on('click.preview', e => { - if ($(e.target).is("a")) { + if (wantsNewWindow(e)) { return; } + const $target = $(e.target); + if ($target.is("a.mention")) { + this.appEvents.trigger('click.discourse-preview-user-card-mention', $target); + } + if ($target.is("a.mention-group")) { + this.appEvents.trigger('click.discourse-preview-group-card-mention-group', $target); + } + if ($target.is("a")) { e.preventDefault(); return false; } diff --git a/app/assets/javascripts/discourse/components/d-navigation.js.es6 b/app/assets/javascripts/discourse/components/d-navigation.js.es6 index ff1e6ef40a..313386c621 100644 --- a/app/assets/javascripts/discourse/components/d-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/d-navigation.js.es6 @@ -13,6 +13,12 @@ export default Ember.Component.extend({ return this.site.get('categoriesList'); }, + @computed('hasDraft') + createTopicLabel(hasDraft) + { + return hasDraft ? 'topic.open_draft': 'topic.create'; + }, + @computed('category.can_edit') showCategoryEdit: canEdit => canEdit, diff --git a/app/assets/javascripts/discourse/components/global-notice.js.es6 b/app/assets/javascripts/discourse/components/global-notice.js.es6 index 06f7e68a16..9ea7f4104b 100644 --- a/app/assets/javascripts/discourse/components/global-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/global-notice.js.es6 @@ -9,6 +9,11 @@ export default Ember.Component.extend(bufferedRender({ buildBuffer(buffer) { let notices = []; + if ($.cookie("dosp") === "1") { + $.cookie("dosp", null, { path: '/' }); + notices.push([I18n.t("forced_anonymous"), 'forced-anonymous']); + } + if (this.session.get('safe_mode')) { notices.push([I18n.t("safe_mode.enabled"), 'safe-mode']); } diff --git a/app/assets/javascripts/discourse/components/group-card-contents.js.es6 b/app/assets/javascripts/discourse/components/group-card-contents.js.es6 new file mode 100644 index 0000000000..e21410fa51 --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-card-contents.js.es6 @@ -0,0 +1,83 @@ +import { setting } from 'discourse/lib/computed'; +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import CardContentsBase from 'discourse/mixins/card-contents-base'; +import CleansUp from 'discourse/mixins/cleans-up'; + +const maxMembersToDisplay = 10; + +export default Ember.Component.extend(CardContentsBase, CleansUp, { + elementId: 'group-card', + triggeringLinkClass: 'mention-group', + classNames: ['no-bg'], + classNameBindings: ['visible:show', 'showBadges', 'hasCardBadgeImage', 'isFixed:fixed'], + allowBackgrounds: setting('allow_profile_backgrounds'), + showBadges: setting('enable_badges'), + + postStream: Ember.computed.alias('topic.postStream'), + viewingTopic: Ember.computed.match('currentPath', /^topic\./), + + showMoreMembers: Ember.computed.gt('moreMembersCount', 0), + + group: null, + + @computed('group.user_count', 'group.members.length') + moreMembersCount: (memberCount, maxMemberDisplay) => memberCount - maxMemberDisplay, + + @computed('group') + groupPath(group) { + return `${Discourse.BaseUri}/groups/${group.name}`; + }, + + _showCallback(username, $target) { + this.store.find("group", username).then(group => { + this.setProperties({ group, visible: true }); + this._positionCard($target); + if(!group.flair_url && !group.flair_bg_color) { + group.set('flair_url', 'fa-users'); + } + group.set('limit', maxMembersToDisplay); + return group.findMembers(); + }).catch(() => this._close()).finally(() => this.set('loading', null)); + }, + + didInsertElement() { + this._super(); + }, + + _close() { + this._super(); + this.setProperties({ + group: null, + }); + }, + + cleanUp() { + this._close(); + }, + + actions: { + close() { + this._close(); + }, + + cancelFilter() { + const postStream = this.get('postStream'); + postStream.cancelFilter(); + postStream.refresh(); + this._close(); + }, + + composePrivateMessage(...args) { + this.sendAction('composePrivateMessage', ...args); + }, + + messageGroup() { + this.sendAction('createNewMessageViaParams', this.get('group.name')); + }, + + showGroup() { + this.sendAction('showGroup', this.get('group')); + this._close(); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6 index 8554742579..7e449b3570 100644 --- a/app/assets/javascripts/discourse/components/search-text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6 @@ -6,7 +6,7 @@ import { applySearchAutocomplete } from "discourse/lib/search"; export default TextField.extend({ @computed('searchService.searchContextEnabled') placeholder(searchContextEnabled) { - return searchContextEnabled ? "" : I18n.t('search.title'); + return searchContextEnabled ? "" : I18n.t('search.full_page_title'); }, @on("didInsertElement") diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index 53ca960699..70dcf5dccc 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -116,51 +116,31 @@ export default Ember.Component.extend({ }, _dock() { - const $topicProgressWrapper = this.$(); - if (!$topicProgressWrapper || $topicProgressWrapper.length === 0) return; + const $wrapper = this.$(); + if (!$wrapper || $wrapper.length === 0) return; - // on desktop, we want the topic-progress after the last post - // on mobile, we want it right before the end of the last post - const progressHeight = this.site.mobileView ? 0 : $("#topic-progress").outerHeight(); + const offset = window.pageYOffset || $("html").scrollTop(); + const progressHeight = this.site.mobileView ? 0 : $("#topic-progress").height(); + const maximumOffset = $("#topic-bottom").offset().top + progressHeight; + const windowHeight = $(window).height(); + const composerHeight = $("#reply-control").height() || 0; + const isDocked = offset >= maximumOffset - windowHeight + composerHeight; + const bottom = $("#main").height() - maximumOffset; - const maximumOffset = $('#topic-bottom').offset(); - const composerHeight = $('#reply-control').height() || 0; - const offset = window.pageYOffset || $('html').scrollTop(); - - const $replyArea = $('#reply-control .reply-area'); - if ($replyArea && $replyArea.length) { - $topicProgressWrapper.css('right', `${$replyArea.offset().left}px`); - } else { - $topicProgressWrapper.css('right', `1em`); - } - - let isDocked = false; - if (maximumOffset) { - const threshold = maximumOffset.top + progressHeight; - const windowHeight = $(window).height(); - - if (this.capabilities.isIOS) { - const headerHeight = $('header').outerHeight(true); - isDocked = offset >= (threshold - windowHeight - headerHeight + composerHeight); - } else { - isDocked = offset >= (threshold - windowHeight + composerHeight); - } - } - - const dockPos = $(document).height() - maximumOffset.top - progressHeight; if (composerHeight > 0) { - if (isDocked) { - $topicProgressWrapper.css('bottom', dockPos); - } else { - const height = composerHeight + "px"; - if ($topicProgressWrapper.css('bottom') !== height) { - $topicProgressWrapper.css('bottom', height); - } - } + $wrapper.css("bottom", isDocked ? bottom : composerHeight); } else { - $topicProgressWrapper.css('bottom', isDocked ? dockPos : ''); + $wrapper.css("bottom", isDocked ? bottom : ""); + } + + this.set("docked", isDocked); + + const $replyArea = $("#reply-control .reply-area"); + if ($replyArea && $replyArea.length > 0) { + $wrapper.css("right", `${$replyArea.offset().left}px`); + } else { + $wrapper.css("right", "1em"); } - this.set('docked', isDocked); }, click(e) { @@ -169,7 +149,6 @@ export default Ember.Component.extend({ } }, - actions: { toggleExpansion() { this.toggleProperty('expanded'); diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 index b7d3b4d31f..0d80e962e1 100644 --- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 @@ -1,45 +1,31 @@ -import { wantsNewWindow } from 'discourse/lib/intercept-click'; -import { propertyNotEqual, setting } from 'discourse/lib/computed'; -import CleansUp from 'discourse/mixins/cleans-up'; -import afterTransition from 'discourse/lib/after-transition'; import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; -import DiscourseURL from 'discourse/lib/url'; import User from 'discourse/models/user'; -import { userPath } from 'discourse/lib/url'; +import { propertyNotEqual, setting } from 'discourse/lib/computed'; import { durationTiny } from 'discourse/lib/formatter'; import CanCheckEmails from 'discourse/mixins/can-check-emails'; +import CardContentsBase from 'discourse/mixins/card-contents-base'; +import CleansUp from 'discourse/mixins/cleans-up'; -const clickOutsideEventName = "mousedown.outside-user-card"; -const clickDataExpand = "click.discourse-user-card"; -const clickMention = "click.discourse-user-mention"; - -export default Ember.Component.extend(CleansUp, CanCheckEmails, { +export default Ember.Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { elementId: 'user-card', - classNameBindings: ['visible:show', 'showBadges', 'hasCardBadgeImage', 'user.card_background::no-bg'], + triggeringLinkClass: 'mention', + classNameBindings: ['visible:show', 'showBadges', 'hasCardBadgeImage', 'user.card_background::no-bg', 'isFixed:fixed'], allowBackgrounds: setting('allow_profile_backgrounds'), + showBadges: setting('enable_badges'), postStream: Ember.computed.alias('topic.postStream'), enoughPostsForFiltering: Ember.computed.gte('topicPostCount', 2), - viewingTopic: Ember.computed.match('currentPath', /^topic\./), - viewingAdmin: Ember.computed.match('currentPath', /^admin\./), showFilter: Ember.computed.and('viewingTopic', 'postStream.hasNoFilters', 'enoughPostsForFiltering'), showName: propertyNotEqual('user.name', 'user.username'), hasUserFilters: Ember.computed.gt('postStream.userFilters.length', 0), isSuspended: Ember.computed.notEmpty('user.suspend_reason'), - showBadges: setting('enable_badges'), showMoreBadges: Ember.computed.gt('moreBadgesCount', 0), showDelete: Ember.computed.and("viewingAdmin", "showName", "user.canBeDeleted"), linkWebsite: Ember.computed.not('user.isBasic'), hasLocationOrWebsite: Ember.computed.or('user.location', 'user.website_name'), showCheckEmail: Ember.computed.and('user.staged', 'canCheckEmails'), - visible: false, user: null, - username: null, - avatar: null, - userLoading: null, - cardTarget: null, - post: null, // If inside a topic topicPostCount: null, @@ -75,21 +61,6 @@ export default Ember.Component.extend(CleansUp, CanCheckEmails, { @computed('user.badge_count', 'user.featured_user_badges.length') moreBadgesCount: (badgeCount, badgeLength) => badgeCount - badgeLength, - @computed('user.card_badge.image') - hasCardBadgeImage: image => image && image.indexOf('fa-') !== 0, - - @observes('user.card_background') - addBackground() { - if (!this.get('allowBackgrounds')) { return; } - - const $this = this.$(); - if (!$this) { return; } - - const url = this.get('user.card_background'); - const bg = Ember.isEmpty(url) ? '' : `url(${Discourse.getURLWithCDN(url)})`; - $this.css('background-image', bg); - }, - @computed('user.time_read', 'user.recent_time_read') showRecentTimeRead(timeRead, recentTimeRead) { return timeRead !== recentTimeRead && recentTimeRead !== 0; @@ -109,144 +80,43 @@ export default Ember.Component.extend(CleansUp, CanCheckEmails, { } }, - _show(username, $target) { - // No user card for anon - if (this.siteSettings.hide_user_profiles_from_public && !this.currentUser) { - return false; - } + @observes('user.card_background') + addBackground() { + if (!this.get('allowBackgrounds')) { return; } - username = Ember.Handlebars.Utils.escapeExpression(username.toString()); + const $this = this.$(); + if (!$this) { return; } - // Don't show on mobile - if (this.site.mobileView) { - DiscourseURL.routeTo(userPath(username)); - return false; - } + const url = this.get('user.card_background'); + const bg = Ember.isEmpty(url) ? '' : `url(${Discourse.getURLWithCDN(url)})`; + $this.css('background-image', bg); + }, - const currentUsername = this.get('username'); - if (username === currentUsername && this.get('userLoading') === username) { - return; - } - - const postId = $target.parents('article').data('post-id'); - - const wasVisible = this.get('visible'); - const previousTarget = this.get('cardTarget'); - const target = $target[0]; - if (wasVisible) { - this._close(); - if (target === previousTarget) { return; } - } - - const post = this.get('viewingTopic') && postId ? this.get('postStream').findLoadedPost(postId) : null; - this.setProperties({ username, userLoading: username, cardTarget: target, post }); + @computed('user.card_badge.image') + hasCardBadgeImage: image => image && image.indexOf('fa-') !== 0, + _showCallback(username, $target) { const args = { stats: false }; args.include_post_count_for = this.get('topic.id'); - User.findByUsername(username, args).then(user => { if (user.topic_post_count) { this.set('topicPostCount', user.topic_post_count[args.include_post_count_for]); } - this.setProperties({ user, avatar: user, visible: true }); - this._positionCard($target); - }).catch(() => this._close()).finally(() => this.set('userLoading', null)); + this.setProperties({ user, visible: true }); - return false; + }).catch(() => this._close()).finally(() => this.set('loading', null)); }, didInsertElement() { this._super(); - afterTransition(this.$(), this._hide.bind(this)); - - $('html').off(clickOutsideEventName) - .on(clickOutsideEventName, (e) => { - if (this.get('visible')) { - const $target = $(e.target); - if ($target.closest('[data-user-card]').data('userCard') || - $target.closest('a.mention').length > 0 || - $target.closest('#user-card').length > 0) { - return; - } - - this._close(); - } - - return true; - }); - - $('#main-outlet').on(clickDataExpand, '[data-user-card]', (e) => { - if (wantsNewWindow(e)) { return; } - const $target = $(e.currentTarget); - return this._show($target.data('user-card'), $target); - }); - - $('#main-outlet').on(clickMention, 'a.mention', (e) => { - if (wantsNewWindow(e)) { return; } - const $target = $(e.target); - return this._show($target.text().replace(/^@/, ''), $target); - }); - }, - - _positionCard(target) { - const rtl = ($('html').css('direction')) === 'rtl'; - if (!target) { return; } - const width = this.$().width(); - - Ember.run.schedule('afterRender', () => { - if (target) { - let position = target.offset(); - if (position) { - - if (rtl) { // The site direction is rtl - position.right = $(window).width() - position.left + 10; - position.left = 'auto'; - let overage = ($(window).width() - 50) - (position.right + width); - if (overage < 0) { - position.right += overage; - position.top += target.height() + 48; - } - } else { // The site direction is ltr - position.left += target.width() + 10; - - let overage = ($(window).width() - 50) - (position.left + width); - if (overage < 0) { - position.left += overage; - position.top += target.height() + 48; - } - } - - position.top -= $('#main-outlet').offset().top; - this.$().css(position); - } - - // After the card is shown, focus on the first link - // - // note: we DO NOT use afterRender here cause _positionCard may - // run afterwards, if we allowed this to happen the usercard - // may be offscreen and we may scroll all the way to it on focus - Ember.run.next(null, () => this.$('a:first').focus() ); - } - }); - }, - - _hide() { - if (!this.get('visible')) { - this.$().css({left: -9999, top: -9999}); - } }, _close() { + this._super(); this.setProperties({ - visible: false, user: null, - username: null, - avatar: null, - userLoading: null, - cardTarget: null, - post: null, - topicPostCount: null + topicPostCount: null, }); }, @@ -254,20 +124,6 @@ export default Ember.Component.extend(CleansUp, CanCheckEmails, { this._close(); }, - keyUp(e) { - if (e.keyCode === 27) { // ESC - const target = this.get('cardTarget'); - this._close(); - target.focus(); - } - }, - - willDestroyElement() { - this._super(); - $('html').off(clickOutsideEventName); - $('#main').off(clickDataExpand).off(clickMention); - }, - actions: { close() { this._close(); diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index 3c9aefe6fb..561197075d 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -33,7 +33,8 @@ export default TextField.extend({ excludeCurrentUser = bool('excludeCurrentUser'), single = bool('single'), allowAny = bool('allowAny'), - disabled = bool('disabled'); + disabled = bool('disabled'), + disallowEmails = bool('disallowEmails'); function excludedUsernames() { // hack works around some issues with allowAny eventing @@ -64,7 +65,8 @@ export default TextField.extend({ allowedUsers, includeMentionableGroups, includeMessageableGroups, - group: self.get("group") + group: self.get("group"), + disallowEmails, }); return results; diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 414e117897..bc64c7e3bf 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -41,7 +41,8 @@ function loadDraft(store, opts) { composerState: Composer.DRAFT, composerTime: draft.composerTime, typingTime: draft.typingTime, - whisper: draft.whisper + whisper: draft.whisper, + tags: draft.tags }); return composer; } @@ -682,7 +683,7 @@ export default Ember.Controller.extend({ } if (opts.topicTitle && opts.topicTitle.length <= this.siteSettings.max_topic_title_length) { - this.set('model.title', opts.topicTitle); + this.set('model.title', escapeExpression(opts.topicTitle)); } if (opts.topicCategoryId) { @@ -707,7 +708,12 @@ export default Ember.Controller.extend({ } if (opts.topicTags && !this.site.mobileView && this.site.get('can_tag_topics')) { - this.set('model.tags', opts.topicTags.split(",")); + const self = this; + let tags = escapeExpression(opts.topicTags).split(",").slice(0, self.siteSettings.max_tags_per_topic); + tags.forEach(function(tag, index, array) { + array[index] = tag.substring(0, self.siteSettings.max_tag_length); + }); + self.set('model.tags', tags); } if (opts.topicBody) { @@ -725,25 +731,26 @@ export default Ember.Controller.extend({ destroyDraft() { const key = this.get('model.draftKey'); if (key) { + if (key === 'new_topic') { + this.send('clearTopicDraft'); + } Draft.clear(key, this.get('model.draftSequence')); } }, cancelComposer() { - const self = this; - - return new Ember.RSVP.Promise(function (resolve) { - if (self.get('model.hasMetaData') || self.get('model.replyDirty')) { + return new Ember.RSVP.Promise((resolve) => { + if (this.get('model.hasMetaData') || this.get('model.replyDirty')) { bootbox.dialog(I18n.t("post.abandon.confirm"), [ { label: I18n.t("post.abandon.no_value") }, { label: I18n.t("post.abandon.yes_value"), 'class': 'btn-danger', - callback(result) { + callback: (result) => { if (result) { - self.destroyDraft(); - self.get('model').clearState(); - self.close(); + this.destroyDraft(); + this.get('model').clearState(); + this.close(); resolve(); } } @@ -751,9 +758,9 @@ export default Ember.Controller.extend({ ]); } else { // it is possible there is some sort of crazy draft with no body ... just give up on it - self.destroyDraft(); - self.get('model').clearState(); - self.close(); + this.destroyDraft(); + this.get('model').clearState(); + this.close(); resolve(); } }); diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 95db043e02..2cc7927f86 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -3,6 +3,8 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import computed from 'ember-addons/ember-computed-decorators'; import { propertyGreaterThan, propertyLessThan } from 'discourse/lib/computed'; import { on } from 'ember-addons/ember-computed-decorators'; +import { default as WhiteLister } from 'pretty-text/white-lister'; +import { sanitize } from 'pretty-text/sanitizer'; function customTagArray(fieldName) { return function() { @@ -187,7 +189,14 @@ export default Ember.Controller.extend(ModalFunctionality, { @computed('viewMode', 'model.body_changes') bodyDiff(viewMode) { - return this.get("model.body_changes." + viewMode); + const html = this.get(`model.body_changes.${viewMode}`); + if (viewMode === "side_by_side_markdown") { + return html; + } else { + const whiteLister = new WhiteLister({ features: { editHistory: true }}); + whiteLister.whiteListFeature("editHistory", { custom: () => true }); + return sanitize(html, whiteLister); + } }, actions: { diff --git a/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 b/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 index 8276b094c1..5a8a32c5d3 100644 --- a/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 @@ -1,3 +1,10 @@ import NavigationDefaultController from 'discourse/controllers/navigation/default'; -export default NavigationDefaultController.extend(); +export default NavigationDefaultController.extend({ + + discoveryCategories: Ember.inject.controller('discovery/categories'), + + draft: function() { + return this.get('discoveryCategories.model.draft'); + }.property('discoveryCategories.model', 'discoveryCategories.model.draft') +}); diff --git a/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 b/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 index 1fa8eedcc7..e56e4dd5f1 100644 --- a/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 +++ b/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 @@ -1,4 +1,9 @@ + export default Ember.Controller.extend({ discovery: Ember.inject.controller(), discoveryTopics: Ember.inject.controller('discovery/topics'), + + draft: function() { + return this.get('discoveryTopics.model.draft'); + }.property('discoveryTopics.model', 'discoveryTopics.model.draft') }); diff --git a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 index dbf9251736..643d1095b2 100644 --- a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 @@ -61,6 +61,10 @@ export default Ember.Controller.extend(BulkTopicSelection, { categories: Ember.computed.alias('site.categoriesList'), + createTopicLabel: function() { + return this.get('list.draft') ? 'topic.open_draft' : 'topic.create'; + }.property('list', 'list.draft'), + @computed('canCreateTopic', 'category', 'canCreateTopicOnCategory') createTopicDisabled(canCreateTopic, category, canCreateTopicOnCategory) { return !canCreateTopic || (category && !canCreateTopicOnCategory); diff --git a/app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 b/app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 deleted file mode 100644 index a2611ebdcb..0000000000 --- a/app/assets/javascripts/discourse/controllers/topic-unsubscribe.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -export default Ember.Controller.extend({ - - stopNotificiationsText: function() { - return I18n.t("topic.unsubscribe.stop_notifications", { title: this.get("model.fancyTitle") }); - }.property("model.fancyTitle"), - -}); diff --git a/app/assets/javascripts/discourse/initializers/message-bus.js.es6 b/app/assets/javascripts/discourse/initializers/message-bus.js.es6 index a0b113765c..11141b4dc0 100644 --- a/app/assets/javascripts/discourse/initializers/message-bus.js.es6 +++ b/app/assets/javascripts/discourse/initializers/message-bus.js.es6 @@ -1,5 +1,20 @@ // Initialize the message bus to receive messages. import pageVisible from 'discourse/lib/page-visible'; +import { handleLogoff } from 'discourse/lib/ajax'; + +function ajax(opts) { + if (opts.complete) { + let oldComplete = opts.complete; + opts.complete = function(xhr, stat) { + handleLogoff(xhr); + oldComplete(xhr, stat); + }; + } else { + opts.complete = handleLogoff; + } + + return $.ajax(opts); +} export default { name: "message-bus", @@ -41,7 +56,7 @@ export default { if (pageVisible()) { opts.headers['Discourse-Visible'] = "true"; } - return $.ajax(opts); + return ajax(opts); }; } else { @@ -50,7 +65,7 @@ export default { if (pageVisible()) { opts.headers['Discourse-Visible'] = "true"; } - return $.ajax(opts); + return ajax(opts); }; messageBus.baseUrl = Discourse.getURL('/'); diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index cce7e5dcba..15e66b3f04 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -13,6 +13,22 @@ export function viewTrackingRequired() { _trackView = true; } +export function handleLogoff(xhr) { + if (xhr.getResponseHeader('Discourse-Logged-Out') && !_showingLogout) { + _showingLogout = true; + const messageBus = Discourse.__container__.lookup('message-bus:main'); + messageBus.stop(); + bootbox.dialog( + I18n.t("logout"), {label: I18n.t("refresh"), callback: logout}, + { + onEscape: () => logout(), + backdrop: 'static' + } + ); + } +}; + + /** Our own $.ajax method. Makes sure the .then method executes in an Ember runloop for performance reasons. Also automatically adjusts the URL to support installs @@ -60,19 +76,6 @@ export function ajax() { args.headers['Discourse-Visible'] = "true"; } - let handleLogoff = function(xhr) { - if (xhr.getResponseHeader('Discourse-Logged-Out') && !_showingLogout) { - _showingLogout = true; - bootbox.dialog( - I18n.t("logout"), {label: I18n.t("refresh"), callback: logout}, - { - onEscape: () => logout(), - backdrop: 'static' - } - ); - } - }; - args.success = (data, textStatus, xhr) => { handleLogoff(xhr); diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6 index 3aa31c4714..e205dea1d0 100644 --- a/app/assets/javascripts/discourse/lib/transform-post.js.es6 +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -27,7 +27,7 @@ export function transformBasicPost(post) { deleted: post.get('deleted'), deleted_at: post.deleted_at, user_deleted: post.user_deleted, - isDeleted: post.deleted_at || post.user_deleted, + isDeleted: post.deleted_at || post.user_deleted, // xxxxx deletedByAvatarTemplate: null, deletedByUsername: null, primary_group_name: post.primary_group_name, @@ -215,7 +215,8 @@ export default function transformPost(currentUser, site, post, prevPost, nextPos postAtts.expandablePost = topic.expandable_first_post; } else { postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover; - postAtts.canDelete = !postAtts.isDeleted && postAtts.canDelete; + postAtts.canDelete = postAtts.canDelete && !post.deleted_at && + currentUser && (currentUser.staff || !post.user_deleted); } _additionalAttributes.forEach(a => postAtts[a] = post[a]); diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index ceed4404e7..a6349e2fa0 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -61,7 +61,7 @@ function organizeResults(r, options) { }); } - if (options.term.match(/@/)) { + if (!options.disallowEmails && options.term.match(/@/)) { let e = { username: options.term }; emails = [ e ]; results.push(e); diff --git a/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 new file mode 100644 index 0000000000..f05fcb55a6 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 @@ -0,0 +1,199 @@ +import { wantsNewWindow } from 'discourse/lib/intercept-click'; +import afterTransition from 'discourse/lib/after-transition'; +import DiscourseURL from 'discourse/lib/url'; +import { userPath } from 'discourse/lib/url'; + +export default Ember.Mixin.create({ + elementId: null, //click detection added for data-{elementId} + triggeringLinkClass: null, //the classname where this card should appear + _showCallback: null, //username, $target - load up data for when show is called, should call this._positionCard($target) when it's done. + + postStream: Ember.computed.alias('topic.postStream'), + viewingTopic: Ember.computed.match('currentPath', /^topic\./), + + visible: false, + username: null, + loading: null, + cardTarget: null, + post: null, + isFixed: false, + + _show(username, $target) { + // No user card for anon + if (this.siteSettings.hide_user_profiles_from_public && !this.currentUser) { + return false; + } + + username = Ember.Handlebars.Utils.escapeExpression(username.toString()); + + // Don't show on mobile + if (this.site.mobileView) { + DiscourseURL.routeTo(userPath(username)); + return false; + } + + const currentUsername = this.get('username'); + if (username === currentUsername && this.get('loading') === username) { + return; + } + + const postId = $target.parents('article').data('post-id'); + + const wasVisible = this.get('visible'); + const previousTarget = this.get('cardTarget'); + const target = $target[0]; + if (wasVisible) { + this._close(); + if (target === previousTarget) { return; } + } + + const post = this.get('viewingTopic') && postId ? this.get('postStream').findLoadedPost(postId) : null; + this.setProperties({ username, loading: username, cardTarget: target, post }); + + this._showCallback(username, $target); + + return false; + }, + + didInsertElement() { + this._super(); + afterTransition(this.$(), this._hide.bind(this)); + const id = this.get('elementId'); + const triggeringLinkClass = this.get('triggeringLinkClass'); + const clickOutsideEventName = `mousedown.outside-${id}`; + const clickDataExpand = `click.discourse-${id}`; + const clickMention = `click.discourse-${id}-${triggeringLinkClass}`; + const previewClickEvent = `click.discourse-preview-${id}-${triggeringLinkClass}`; + + this.setProperties({ clickOutsideEventName, clickDataExpand, clickMention, previewClickEvent }); + + $('html').off(clickOutsideEventName) + .on(clickOutsideEventName, (e) => { + if (this.get('visible')) { + const $target = $(e.target); + if ($target.closest(`[data-${id}]`).data(id) || + $target.closest(`a.${triggeringLinkClass}`).length > 0 || + $target.closest(`#${id}`).length > 0) { + return; + } + + this._close(); + } + + return true; + }); + + $('#main-outlet').on(clickDataExpand, `[data-${id}]`, (e) => { + if (wantsNewWindow(e)) { return; } + const $target = $(e.currentTarget); + return this._show($target.data(id), $target); + }); + + $('#main-outlet').on(clickMention, `a.${triggeringLinkClass}`, (e) => { + if (wantsNewWindow(e)) { return; } + const $target = $(e.target); + return this._show($target.text().replace(/^@/, ''), $target); + }); + + this.appEvents.on(previewClickEvent, $target => { + this.set('isFixed', true); + return this._show($target.text().replace(/^@/, ''), $target); + }); + }, + + _positionCard(target) { + const rtl = ($('html').css('direction')) === 'rtl'; + if (!target) { return; } + const width = this.$().width(); + const height = 175; + const isFixed = this.get('isFixed'); + + let verticalAdjustments = 0; + + Ember.run.schedule('afterRender', () => { + if (target) { + let position = target.offset(); + if (position) { + position.bottom = 'unset'; + + if (rtl) { // The site direction is rtl + position.right = $(window).width() - position.left + 10; + position.left = 'auto'; + let overage = ($(window).width() - 50) - (position.right + width); + if (overage < 0) { + position.right += overage; + position.top += target.height() + 48; + verticalAdjustments += target.height() + 48; + } + } else { // The site direction is ltr + position.left += target.width() + 10; + + let overage = ($(window).width() - 50) - (position.left + width); + if (overage < 0) { + position.left += overage; + position.top += target.height() + 48; + verticalAdjustments += target.height() + 48; + } + } + + position.top -= $('#main-outlet').offset().top; + if(isFixed) { + position.top -= $('html').scrollTop(); + //if content is fixed and will be cut off on the bottom, display it above... + if(position.top + height + verticalAdjustments > $(window).height() - 50) { + position.bottom = $(window).height() - (target.offset().top - $('html').scrollTop()); + if(verticalAdjustments > 0) { + position.bottom += 48; + } + position.top = 'unset'; + } + } + this.$().css(position); + } + + // After the card is shown, focus on the first link + // + // note: we DO NOT use afterRender here cause _positionCard may + // run afterwards, if we allowed this to happen the usercard + // may be offscreen and we may scroll all the way to it on focus + Ember.run.next(null, () => this.$('a:first').focus() ); + } + }); + }, + + _hide() { + if (!this.get('visible')) { + this.$().css({left: -9999, top: -9999}); + } + }, + + _close() { + this.setProperties({ + visible: false, + username: null, + loading: null, + cardTarget: null, + post: null, + isFixed: false + }); + }, + + willDestroyElement() { + this._super(); + const clickOutsideEventName = this.get('clickOutsideEventName'); + const clickDataExpand = this.get('clickDataExpand'); + const clickMention = this.get('clickMention'); + const previewClickEvent = this.get('previewClickEvent'); + $('html').off(clickOutsideEventName); + $('#main').off(clickDataExpand).off(clickMention); + this.appEvents.off(previewClickEvent); + }, + + keyUp(e) { + if (e.keyCode === 27) { // ESC + const target = this.get('cardTarget'); + this._close(); + target.focus(); + } + } +}); diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 8e4c6b60ae..cefd6df7dd 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -518,7 +518,8 @@ const Composer = RestModel.extend({ targetUsernames: opts.usernames, composerTotalOpened: opts.composerTime, typingTime: opts.typingTime, - whisper: opts.whisper + whisper: opts.whisper, + tags: opts.tags }); if (opts.post) { @@ -836,7 +837,8 @@ const Composer = RestModel.extend({ metaData: this.get('metaData'), usernames: this.get('targetUsernames'), composerTime: this.get('composerTime'), - typingTime: this.get('typingTime') + typingTime: this.get('typingTime'), + tags: this.get('tags') }; this.set('draftStatus', I18n.t('composer.saving_draft_tip')); diff --git a/app/assets/javascripts/discourse/models/tag-group.js.es6 b/app/assets/javascripts/discourse/models/tag-group.js.es6 index f433038abb..020f2b9901 100644 --- a/app/assets/javascripts/discourse/models/tag-group.js.es6 +++ b/app/assets/javascripts/discourse/models/tag-group.js.es6 @@ -1,6 +1,7 @@ import { ajax } from 'discourse/lib/ajax'; import RestModel from 'discourse/models/rest'; import computed from 'ember-addons/ember-computed-decorators'; +import PermissionType from 'discourse/models/permission-type'; const TagGroup = RestModel.extend({ @computed('name', 'tag_names') @@ -8,6 +9,31 @@ const TagGroup = RestModel.extend({ return Ember.isEmpty(this.get('name')) || Ember.isEmpty(this.get('tag_names')) || this.get('saving'); }, + @computed('permissions') + permissionName: { + get(permissions) { + if (!permissions) return 'public'; + + if (permissions['everyone'] === PermissionType.FULL) { + return 'public'; + } else if (permissions['everyone'] === PermissionType.READONLY) { + return 'visible'; + } else { + return 'private'; + } + }, + + set(value) { + if (value === 'private') { + this.set('permissions', {'staff': PermissionType.FULL}); + } else if (value === 'visible') { + this.set('permissions', {'staff': PermissionType.FULL, 'everyone': PermissionType.READONLY}); + } else { + this.set('permissions', {'everyone': PermissionType.FULL}); + } + } + }, + save() { let url = "/tag_groups"; const self = this, @@ -25,7 +51,7 @@ const TagGroup = RestModel.extend({ tag_names: this.get('tag_names'), parent_tag_name: this.get('parent_tag_name') ? this.get('parent_tag_name') : undefined, one_per_topic: this.get('one_per_topic'), - permissions: this.get('visible_only_to_staff') ? {"staff": "1"} : {"everyone": "1"} + permissions: this.get('permissions') }, type: isNew ? 'POST' : 'PUT' }).then(function(result) { diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 6df5cbe8c2..bf37af7917 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -101,7 +101,7 @@ const Topic = RestModel.extend({ const newTags = []; tags.forEach(function(tag){ - if (title.toLowerCase().indexOf(tag) === -1 || Discourse.SiteSettings.staff_tags.indexOf(tag) !== -1) { + if (title.toLowerCase().indexOf(tag) === -1) { newTags.push(tag); } }); diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index f489faf4bb..da873ecf9d 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -14,7 +14,6 @@ export default function() { }); this.route('topicBySlugOrId', { path: '/t/:slugOrId', resetNamespace: true }); - this.route('topicUnsubscribe', { path: '/t/:slug/:id/unsubscribe' }); this.route('discovery', { path: '/', resetNamespace: true }, function() { // top 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 4b29783e56..fb4f1b7fe8 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -118,8 +118,6 @@ export default (filterArg, params) => { this.controllerFor('discovery/topics').setProperties(topicOpts); this.searchService.set('searchContext', category.get('searchContext')); this.set('topics', null); - - this.openTopicDraft(topics); }, renderTemplate() { 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 a7c1dc00ee..198ab74e49 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -106,8 +106,6 @@ export default function(filter, extras) { } } this.controllerFor('discovery/topics').setProperties(topicOpts); - - this.openTopicDraft(model); this.controllerFor('navigation/default').set('canCreateTopic', model.get('can_create_topic')); }, diff --git a/app/assets/javascripts/discourse/routes/discourse.js.es6 b/app/assets/javascripts/discourse/routes/discourse.js.es6 index 42595a0956..f726cb5d8f 100644 --- a/app/assets/javascripts/discourse/routes/discourse.js.es6 +++ b/app/assets/javascripts/discourse/routes/discourse.js.es6 @@ -52,7 +52,25 @@ const DiscourseRoute = Ember.Route.extend({ refreshTitle() { Ember.run.once(this, this._refreshTitleOnce); + }, + + clearTopicDraft() { + // perhaps re-delegate this to root controller in all cases? + // TODO also poison the store so it does not come back from the + // dead + if (this.get('controller.list.draft')) { + this.set('controller.list.draft', null); + } + + if (this.controllerFor("discovery/categories").get('model.draft')) { + this.controllerFor("discovery/categories").set('model.draft', null); + } + + if (this.controllerFor("discovery/topics").get('model.draft')) { + this.controllerFor("discovery/topics").set('model.draft', null); + } } + }, redirectIfLoginRequired() { @@ -63,17 +81,18 @@ const DiscourseRoute = Ember.Route.extend({ }, openTopicDraft(model){ - // If there's a draft, open the create topic composer - if (model.draft) { - const composer = this.controllerFor('composer'); - if (!composer.get('model.viewOpen')) { - composer.open({ - action: Composer.CREATE_TOPIC, - draft: model.draft, - draftKey: model.draft_key, - draftSequence: model.draft_sequence - }); - } + const composer = this.controllerFor('composer'); + + if (composer.get('model.action') === Composer.CREATE_TOPIC && + composer.get('model.draftKey') === model.draft_key) { + composer.set('model.composeState', Composer.OPEN); + } else { + composer.open({ + action: Composer.CREATE_TOPIC, + draft: model.draft, + draftKey: model.draft_key, + draftSequence: model.draft_sequence + }); } }, diff --git a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 index 9753a776eb..a85c0743f6 100644 --- a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 @@ -89,8 +89,6 @@ const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, { showCategoryAdmin: model.get("can_create_category"), canCreateTopic: model.get("can_create_topic"), }); - - this.openTopicDraft(model); }, actions: { @@ -133,7 +131,12 @@ const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, { }, createTopic() { - this.openComposer(this.controllerFor("discovery/categories")); + const model = this.controllerFor("discovery/categories").get('model'); + if (model.draft) { + this.openTopicDraft(model); + } else { + this.openComposer(this.controllerFor("discovery/categories")); + } }, didTransition() { diff --git a/app/assets/javascripts/discourse/routes/discovery.js.es6 b/app/assets/javascripts/discourse/routes/discovery.js.es6 index a77806d1a4..b8b05fe35e 100644 --- a/app/assets/javascripts/discourse/routes/discovery.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery.js.es6 @@ -45,7 +45,12 @@ export default Discourse.Route.extend(OpenComposer, { }, createTopic() { - this.openComposer(this.controllerFor("discovery/topics")); + const model = this.controllerFor("discovery/topics").get('model'); + if (model.draft) { + this.openTopicDraft(model); + } else { + this.openComposer(this.controllerFor("discovery/topics")); + } }, dismissReadTopics(dismissTopics) { diff --git a/app/assets/javascripts/discourse/routes/tags-show.js.es6 b/app/assets/javascripts/discourse/routes/tags-show.js.es6 index eec3a6b362..68e37e44ef 100644 --- a/app/assets/javascripts/discourse/routes/tags-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/tags-show.js.es6 @@ -129,18 +129,22 @@ export default Discourse.Route.extend({ var controller = this.controllerFor("tags.show"), self = this; - this.controllerFor('composer').open({ - categoryId: controller.get('category.id'), - action: Composer.CREATE_TOPIC, - draftKey: controller.get('list.draft_key'), - draftSequence: controller.get('list.draft_sequence') - }).then(function() { - // Pre-fill the tags input field - if (controller.get('model.id')) { - var c = self.controllerFor('composer').get('model'); - c.set('tags', _.flatten([controller.get('model.id')], controller.get('additionalTags'))); - } - }); + if (controller.get('list.draft')) { + this.openTopicDraft(controller.get('list')); + } else { + this.controllerFor('composer').open({ + categoryId: controller.get('category.id'), + action: Composer.CREATE_TOPIC, + draftKey: controller.get('list.draft_key'), + draftSequence: controller.get('list.draft_sequence') + }).then(function() { + // Pre-fill the tags input field + if (controller.get('model.id')) { + var c = self.controllerFor('composer').get('model'); + c.set('tags', _.flatten([controller.get('model.id')], controller.get('additionalTags'))); + } + }); + } }, didTransition() { diff --git a/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 b/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 deleted file mode 100644 index 2faf69d0fb..0000000000 --- a/app/assets/javascripts/discourse/routes/topic-unsubscribe.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -import { loadTopicView } from 'discourse/models/topic'; - -export default Discourse.Route.extend({ - model(params) { - const topic = this.store.createRecord("topic", { id: params.id }); - return loadTopicView(topic).then(() => topic); - }, - - afterModel(topic) { - topic.set("details.notificationReasonText", null); - }, - - actions: { - didTransition() { - this.controllerFor("application").set("showFooter", true); - return true; - } - } -}); diff --git a/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs index 4537c45160..611ba05f3a 100644 --- a/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs +++ b/app/assets/javascripts/discourse/templates/badge-selector-autocomplete.raw.hbs @@ -1,4 +1,4 @@ -
+