diff --git a/.gitignore b/.gitignore index 601f196f5c..cf4493d442 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ discourse.sublime-workspace *~ *.swp *.swo +*.swm # don't check in multisite config config/multisite.yml diff --git a/Gemfile b/Gemfile index 4a7c9a2147..c89be78a7c 100644 --- a/Gemfile +++ b/Gemfile @@ -185,3 +185,5 @@ if ENV["IMPORT"] == "1" gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md' gem 'reverse_markdown' end + +gem 'webpush', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 668d8882a2..c5b26bd87c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,7 @@ GEM hashie (3.5.5) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.0.5) i18n (0.8.6) @@ -164,7 +165,7 @@ GEM mail (2.7.1.rc1) mini_mime (>= 0.1.1) memory_profiler (0.9.10) - message_bus (2.1.4) + message_bus (2.1.5) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) @@ -251,7 +252,7 @@ GEM public_suffix (2.0.5) puma (3.9.1) r2 (0.2.6) - rack (2.0.4) + rack (2.0.5) rack-mini-profiler (1.0.0) rack (>= 1.2.0) rack-openid (1.3.1) @@ -388,6 +389,9 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff + webpush (0.3.2) + hkdf (~> 0.2) + jwt PLATFORMS ruby @@ -498,6 +502,7 @@ DEPENDENCIES unf unicorn webmock + webpush BUNDLED WITH 1.16.1 diff --git a/app/assets/images/push-notifications/check.png b/app/assets/images/push-notifications/check.png new file mode 100644 index 0000000000..ad223d26f3 Binary files /dev/null and b/app/assets/images/push-notifications/check.png differ diff --git a/app/assets/images/push-notifications/discourse.png b/app/assets/images/push-notifications/discourse.png new file mode 100644 index 0000000000..46d9720633 Binary files /dev/null and b/app/assets/images/push-notifications/discourse.png differ diff --git a/app/assets/images/push-notifications/group_mentioned.png b/app/assets/images/push-notifications/group_mentioned.png new file mode 100644 index 0000000000..ebb5560414 Binary files /dev/null and b/app/assets/images/push-notifications/group_mentioned.png differ diff --git a/app/assets/images/push-notifications/linked.png b/app/assets/images/push-notifications/linked.png new file mode 100644 index 0000000000..5e25f2426a Binary files /dev/null and b/app/assets/images/push-notifications/linked.png differ diff --git a/app/assets/images/push-notifications/mentioned.png b/app/assets/images/push-notifications/mentioned.png new file mode 100644 index 0000000000..ebb5560414 Binary files /dev/null and b/app/assets/images/push-notifications/mentioned.png differ diff --git a/app/assets/images/push-notifications/posted.png b/app/assets/images/push-notifications/posted.png new file mode 100644 index 0000000000..41d02aff0e Binary files /dev/null and b/app/assets/images/push-notifications/posted.png differ diff --git a/app/assets/images/push-notifications/private_message.png b/app/assets/images/push-notifications/private_message.png new file mode 100644 index 0000000000..8e71e69c7f Binary files /dev/null and b/app/assets/images/push-notifications/private_message.png differ diff --git a/app/assets/images/push-notifications/quoted.png b/app/assets/images/push-notifications/quoted.png new file mode 100644 index 0000000000..01d889b468 Binary files /dev/null and b/app/assets/images/push-notifications/quoted.png differ diff --git a/app/assets/images/push-notifications/replied.png b/app/assets/images/push-notifications/replied.png new file mode 100644 index 0000000000..41d02aff0e Binary files /dev/null and b/app/assets/images/push-notifications/replied.png differ diff --git a/app/assets/javascripts/admin/components/admin-report-counts.js.es6 b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 index 46ab32f609..1739a186b3 100644 --- a/app/assets/javascripts/admin/components/admin-report-counts.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 @@ -1,4 +1,5 @@ export default Ember.Component.extend({ + allTime: true, tagName: 'tr', reverseColors: Ember.computed.match('report.type', /^(time_to_first_response|topics_with_no_response)$/), classNameBindings: ['reverseColors'] diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 index 22443906ac..82eceb6691 100644 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -1,23 +1,38 @@ -import { ajax } from 'discourse/lib/ajax'; +import { ajax } from "discourse/lib/ajax"; import Report from "admin/models/report"; import AsyncReport from "admin/mixins/async-report"; export default Ember.Component.extend(AsyncReport, { classNames: ["dashboard-table", "dashboard-inline-table", "fixed"], - isLoading: true, help: null, helpPage: null, - fetchReport() { - this.set("isLoading", true); + loadReport(report_json) { + return Report.create(report_json); + }, - ajax(this.get("dataSource")) - .then((response) => { - this._setPropertiesFromReport(Report.create(response.report)); - }).finally(() => { - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - }; - }); + fetchReport() { + this._super(); + + let payload = { data: { cache: true, facets: ["total", "prev30Days"] } }; + + if (this.get("startDate")) { + payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("endDate")) { + payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("limit")) { + payload.data.limit = this.get("limit"); + } + + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.get("reports").pushObject(this.loadReport(response.report)); + }); + })); } }); diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 index f5ff432a84..0ac44e2bbb 100644 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -1,123 +1,151 @@ import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; import AsyncReport from "admin/mixins/async-report"; import Report from "admin/models/report"; import { number } from 'discourse/lib/formatter'; +import loadScript from "discourse/lib/load-script"; +import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip"; + +function collapseWeekly(data, average) { + let aggregate = []; + let bucket, i; + let offset = data.length % 7; + for(i = offset; i < data.length; i++) { + + if (bucket && (i % 7 === offset)) { + if (average) { + bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); + } + aggregate.push(bucket); + bucket = null; + } + + bucket = bucket || { x: data[i].x, y: 0 }; + bucket.y += data[i].y; + } + return aggregate; +} export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-mini-chart"], - classNameBindings: ["thirtyDayTrend", "oneDataPoint"], - isLoading: true, - thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"), - oneDataPoint: false, - backgroundColor: "rgba(200,220,240,0.3)", - borderColor: "#08C", - average: false, + classNames: ["chart", "dashboard-mini-chart"], + total: 0, - willDestroyEelement() { + init() { this._super(); - this.messageBus.unsubscribe(this.get("dataSource")); + this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"]; }, - @computed("dataSourceName") - dataSource(dataSourceName) { - if (dataSourceName) { - return `/admin/reports/${dataSourceName}`; - } + didRender() { + this._super(); + registerTooltip($(this.element).find("[data-tooltip]")); }, - @computed("thirtyDayTrend") - trendIcon(thirtyDayTrend) { - switch (thirtyDayTrend) { - case "trending-up": - return "angle-up"; - case "trending-down": - return "angle-down"; - case "high-trending-up": - return "angle-double-up"; - case "high-trending-down": - return "angle-double-down"; - default: - return null; - } + willDestroyElement() { + this._super(); + unregisterTooltip($(this.element).find("[data-tooltip]")); + }, + + pickColorAtIndex(index) { + return this._colorsPool[index] || this._colorsPool[0]; }, fetchReport() { - this.set("isLoading", true); + this._super(); let payload = { - data: { async: true } + data: { cache: true, facets: ["prev_period"] } }; if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); + payload.data.start_date = this.get("startDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); } if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); + payload.data.end_date = this.get("endDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); } - ajax(this.get("dataSource"), payload) - .then((response) => { - // if (!Ember.isEmpty(response.report.data)) { - this._setPropertiesFromReport(Report.create(response.report)); - // } - }) - .finally(() => { - if (this.get("oneDataPoint")) { - this.set("isLoading", false); - return; - } + if (this._chart) { + this._chart.destroy(); + this._chart = null; + } - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - this.renderReport(); - } - }); + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.get("reports").pushObject(this.loadReport(response.report)); + }); + })); + }, + + loadReport(report, previousReport) { + Report.fillMissingDates(report); + + if (report.data && report.data.length > 40) { + report.data = collapseWeekly(report.data, report.average); + } + + if (previousReport && previousReport.color.length) { + report.color = previousReport.color; + } else { + const dataSourceNameIndex = this.get("dataSourceNames").split(",").indexOf(report.type); + report.color = this.pickColorAtIndex(dataSourceNameIndex); + } + + return Report.create(report); }, renderReport() { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - if (this.get("oneDataPoint")) return; + this._super(); Ember.run.schedule("afterRender", () => { const $chartCanvas = this.$(".chart-canvas"); - if (!$chartCanvas.length) return; const context = $chartCanvas[0].getContext("2d"); + const reportsForPeriod = this.get("reportsForPeriod"); + + const labels = Ember.makeArray(reportsForPeriod.get("firstObject.data")).map(d => d.x); + const data = { - labels: this.get("labels"), - datasets: [{ - data: Ember.makeArray(this.get("values")), - backgroundColor: this.get("backgroundColor"), - borderColor: this.get("borderColor") - }] + labels, + datasets: reportsForPeriod.map(report => { + return { + data: Ember.makeArray(report.data).map(d => d.y), + backgroundColor: "rgba(200,220,240,0.3)", + borderColor: report.color + }; + }) }; - this._chart = new window.Chart(context, this._buildChartConfig(data)); + if (this._chart) { + this._chart.destroy(); + this._chart = null; + } + + loadScript("/javascripts/Chart.min.js").then(() => { + if (this._chart) { + this._chart.destroy(); + } + this._chart = new window.Chart(context, this._buildChartConfig(data)); + }); }); }, - _setPropertiesFromReport(report) { - const oneDataPoint = (this.get("startDate") && this.get("endDate")) && - this.get("startDate").isSame(this.get("endDate"), "day"); - - report.set("average", this.get("average")); - - this.setProperties({ oneDataPoint, report }); - }, - _buildChartConfig(data) { return { type: "line", data, options: { + tooltips: { + callbacks: { + title: (context) => moment(context[0].xLabel, "YYYY-MM-DD").format("LL") + } + }, legend: { display: false }, responsive: true, + maintainAspectRatio: false, layout: { padding: { left: 0, @@ -133,6 +161,7 @@ export default Ember.Component.extend(AsyncReport, { }], xAxes: [{ display: true, + gridLines: { display: false }, type: "time", time: { parser: "YYYY-MM-DD" 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 deleted file mode 100644 index f6523f6841..0000000000 --- a/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 +++ /dev/null @@ -1,8 +0,0 @@ -import DashboardTable from "admin/components/dashboard-table"; -import AsyncReport from "admin/mixins/async-report"; - -export default DashboardTable.extend(AsyncReport, { - layoutName: "admin/templates/components/dashboard-table", - - classNames: ["dashboard-table", "dashboard-table-trending-search"] -}); diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6 deleted file mode 100644 index b674836707..0000000000 --- a/app/assets/javascripts/admin/components/dashboard-table.js.es6 +++ /dev/null @@ -1,50 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import Report from "admin/models/report"; -import AsyncReport from "admin/mixins/async-report"; -import computed from "ember-addons/ember-computed-decorators"; -import { number } from 'discourse/lib/formatter'; - -export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-table"], - help: null, - helpPage: null, - - @computed("report") - values(report) { - if (!report) return; - return Ember.makeArray(report.data) - .sort((a, b) => a.x >= b.x) - .map(x => { - return [ x[0], number(x[1]), number(x[2]) ]; - }); - }, - - @computed("report") - labels(report) { - if (!report) return; - return Ember.makeArray(report.labels); - }, - - fetchReport() { - this.set("isLoading", true); - - let payload = { data: { async: true } }; - - if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - ajax(this.get("dataSource"), payload) - .then((response) => { - this._setPropertiesFromReport(Report.create(response.report)); - }).finally(() => { - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - }; - }); - } -}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 index 2d7fc62183..2f24cf9edd 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 @@ -1,15 +1,39 @@ +import { setting } from "discourse/lib/computed"; 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"; +import VersionCheck from "admin/models/version-check"; + +const PROBLEMS_CHECK_MINUTES = 1; export default Ember.Controller.extend({ queryParams: ["period"], - period: "all", + period: "monthly", isLoading: false, dashboardFetchedAt: null, exceptionController: Ember.inject.controller("exception"), + showVersionChecks: setting("version_checks"), diskSpace: Ember.computed.alias("model.attributes.disk_space"), + logSearchQueriesEnabled: setting("log_search_queries"), + availablePeriods: ["yearly", "quarterly", "monthly", "weekly"], + + @computed("problems.length") + foundProblems(problemsLength) { + return this.currentUser.get("admin") && (problemsLength || 0) > 0; + }, + + @computed("foundProblems") + thereWereProblems(foundProblems) { + if (!this.currentUser.get("admin")) { return false; } + + if (foundProblems) { + this.set("hadProblems", true); + return true; + } else { + return this.get("hadProblems") || false; + } + }, fetchDashboard() { if (this.get("isLoading")) return; @@ -17,7 +41,14 @@ export default Ember.Controller.extend({ if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) { this.set("isLoading", true); + const versionChecks = this.siteSettings.version_checks; + AdminDashboardNext.find().then(adminDashboardNextModel => { + + if (versionChecks) { + this.set("versionCheck", VersionCheck.create(adminDashboardNextModel.version_check)); + } + this.setProperties({ dashboardFetchedAt: new Date(), model: adminDashboardNextModel, @@ -30,34 +61,57 @@ export default Ember.Controller.extend({ this.set("isLoading", false); }); } + + if (!this.get("problemsFetchedAt") || moment().subtract(PROBLEMS_CHECK_MINUTES, "minutes").toDate() > this.get("problemsFetchedAt")) { + this.loadProblems(); + } + }, + + loadProblems() { + this.set("loadingProblems", true); + this.set("problemsFetchedAt", new Date()); + AdminDashboardNext.fetchProblems().then(d => { + this.set("problems", d.problems); + }).finally(() => { + this.set("loadingProblems", false); + }); + }, + + @computed("problemsFetchedAt") + problemsTimestamp(problemsFetchedAt) { + return moment(problemsFetchedAt).locale("en").format("LLL"); }, @computed("period") startDate(period) { + let fullDay = moment().locale("en").utc().subtract(1, "day"); + switch (period) { case "yearly": - return moment().subtract(1, "year").startOf("day"); + return fullDay.subtract(1, "year").startOf("day"); break; case "quarterly": - return moment().subtract(3, "month").startOf("day"); + return fullDay.subtract(3, "month").startOf("day"); break; case "weekly": - return moment().subtract(1, "week").startOf("day"); + return fullDay.subtract(1, "week").startOf("day"); break; case "monthly": - return moment().subtract(1, "month").startOf("day"); - break; - case "daily": - return moment().startOf("day"); + return fullDay.subtract(1, "month").startOf("day"); break; default: - return null; + return fullDay.subtract(1, "month").startOf("day"); } }, - @computed("period") - endDate(period) { - return period === "all" ? null : moment().endOf("day"); + @computed() + lastWeek() { + return moment().locale("en").utc().endOf("day").subtract(1, "week"); + }, + + @computed() + endDate() { + return moment().locale("en").utc().subtract(1, "day").endOf("day"); }, @computed("updated_at") @@ -73,10 +127,13 @@ export default Ember.Controller.extend({ actions: { changePeriod(period) { DiscourseURL.routeTo(this._reportsForPeriodURL(period)); - } + }, + refreshProblems() { + this.loadProblems(); + }, }, _reportsForPeriodURL(period) { - return `/admin/dashboard-next?period=${period}`; + return Discourse.getURL(`/admin?period=${period}`); } }); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 index 6652002fb4..973cd3d57c 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 @@ -1,11 +1,8 @@ -import { setting } from 'discourse/lib/computed'; import AdminDashboard from 'admin/models/admin-dashboard'; -import VersionCheck from 'admin/models/version-check'; import Report from 'admin/models/report'; import AdminUser from 'admin/models/admin-user'; import computed from 'ember-addons/ember-computed-decorators'; -const PROBLEMS_CHECK_MINUTES = 1; const ATTRIBUTES = [ 'disk_space','admins', 'moderators', 'silenced', 'suspended', 'top_traffic_sources', 'top_referred_topics', 'updated_at']; @@ -18,35 +15,13 @@ export default Ember.Controller.extend({ loading: null, versionCheck: null, dashboardFetchedAt: null, - showVersionChecks: setting('version_checks'), exceptionController: Ember.inject.controller('exception'), - @computed('problems.length') - foundProblems(problemsLength) { - return this.currentUser.get('admin') && (problemsLength || 0) > 0; - }, - - @computed('foundProblems') - thereWereProblems(foundProblems) { - if (!this.currentUser.get('admin')) { return false; } - - if (foundProblems) { - this.set('hadProblems', true); - return true; - } else { - return this.get('hadProblems') || false; - } - }, - fetchDashboard() { if (!this.get('dashboardFetchedAt') || moment().subtract(30, 'minutes').toDate() > this.get('dashboardFetchedAt')) { this.set('loading', true); - const versionChecks = this.siteSettings.version_checks; AdminDashboard.find().then(d => { this.set('dashboardFetchedAt', new Date()); - if (versionChecks) { - this.set('versionCheck', VersionCheck.create(d.version_check)); - } REPORTS.forEach(name => this.set(name, d[name].map(r => Report.create(r)))); @@ -64,26 +39,8 @@ export default Ember.Controller.extend({ this.set('loading', false); }); } - - if (!this.get('problemsFetchedAt') || moment().subtract(PROBLEMS_CHECK_MINUTES, 'minutes').toDate() > this.get('problemsFetchedAt')) { - this.loadProblems(); - } }, - loadProblems() { - this.set('loadingProblems', true); - this.set('problemsFetchedAt', new Date()); - AdminDashboard.fetchProblems().then(d => { - this.set('problems', d.problems); - }).finally(() => { - this.set('loadingProblems', false); - }); - }, - - @computed('problemsFetchedAt') - problemsTimestamp(problemsFetchedAt) { - return moment(problemsFetchedAt).format('LLL'); - }, @computed('updated_at') updatedTimestamp(updatedAt) { @@ -91,9 +48,6 @@ export default Ember.Controller.extend({ }, actions: { - refreshProblems() { - this.loadProblems(); - }, showTrafficReport() { this.set("showTrafficReport", true); } diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 index df0738bed1..c2d96201bd 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 @@ -1,16 +1,16 @@ +import computed from "ember-addons/ember-computed-decorators"; + export default Ember.Controller.extend({ categoryNameKey: null, adminSiteSettings: Ember.inject.controller(), - filteredContent: function() { - if (!this.get('categoryNameKey')) { return []; } - - const category = (this.get('adminSiteSettings.model') || []).findBy('nameKey', this.get('categoryNameKey')); - if (category) { - return category.siteSettings; - } else { - return []; - } - }.property('adminSiteSettings.model', 'categoryNameKey') + @computed("adminSiteSettings.model", "categoryNameKey") + category(categories, nameKey) { + return (categories || []).findBy("nameKey", nameKey); + }, + @computed("category") + filteredContent(category) { + return category ? category.siteSettings : []; + } }); diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 6edbe9e01f..6469efa6cc 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -3,7 +3,6 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ filter: null, onlyOverridden: false, - filtered: Ember.computed.notEmpty('filter'), filterContentNow(category) { // If we have no content, don't bother filtering anything @@ -14,9 +13,9 @@ export default Ember.Controller.extend({ filter = this.get('filter').toLowerCase(); } - if ((filter === undefined || filter.length < 1) && !this.get('onlyOverridden')) { + if ((!filter || 0 === filter.length) && !this.get('onlyOverridden')) { this.set('model', this.get('allSiteSettings')); - this.transitionToRoute("adminSiteSettings"); + this.transitionToRoute('adminSiteSettings'); return; } @@ -28,11 +27,11 @@ export default Ember.Controller.extend({ const siteSettings = settingsCategory.siteSettings.filter(item => { if (this.get('onlyOverridden') && !item.get('overridden')) return false; if (filter) { - if (item.get('setting').toLowerCase().indexOf(filter) > -1) return true; - if (item.get('setting').toLowerCase().replace(/_/g, ' ').indexOf(filter) > -1) return true; - if (item.get('description').toLowerCase().indexOf(filter) > -1) return true; - if ((item.get('value') || '').toLowerCase().indexOf(filter) > -1) return true; - return false; + const setting = item.get('setting').toLowerCase(); + return setting.includes(filter) || + setting.replace(/_/g, ' ').includes(filter) || + item.get('description').toLowerCase().includes(filter) || + (item.get('value') || '').toLowerCase().includes(filter); } else { return true; } @@ -49,15 +48,16 @@ export default Ember.Controller.extend({ }); all.siteSettings.pushObjects(matches.slice(0, 30)); - all.count = matches.length; + all.hasMore = matches.length > 30; + all.count = all.hasMore ? '30+' : matches.length; this.set('model', matchesGroupedByCategory); - this.transitionToRoute("adminSiteSettingsCategory", category || "all_results"); + this.transitionToRoute('adminSiteSettingsCategory', category || 'all_results'); }, filterContent: debounce(function() { - if (this.get("_skipBounce")) { - this.set("_skipBounce", false); + if (this.get('_skipBounce')) { + this.set('_skipBounce', false); } else { this.filterContentNow(); } diff --git a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 index 56e43ccbd8..f47869d646 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 @@ -5,6 +5,24 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; const THEME_FIELD_VARIABLE_TYPE_IDS = [2, 3, 4]; +const SCSS_VARIABLE_NAMES = [ + // common/foundation/colors.scss + "primary", "secondary", "tertiary", "quaternary", "header_background", + "header_primary", "highlight", "danger", "success", "love", + // common/foundation/math.scss + "E", "PI", "LN2", "SQRT2", + // common/foundation/variables.scss + "small-width", "medium-width", "large-width", + "google", "instagram", "facebook", "cas", "twitter", "yahoo", "github", + "base-font-size", "base-line-height", "base-font-family", + "primary-low", "primary-medium", + "secondary-low", "secondary-medium", + "tertiary-low", "quaternary-low", + "highlight-low", "highlight-medium", + "danger-low", "danger-medium", + "success-low", "love-low", +]; + export default Ember.Controller.extend(ModalFunctionality, { adminCustomizeThemesShow: Ember.inject.controller(), @@ -19,10 +37,23 @@ export default Ember.Controller.extend(ModalFunctionality, { disabled: Em.computed.not('enabled'), @computed('name', 'adminCustomizeThemesShow.model.theme_fields') - nameValid(name, themeFields) { - return name && - name.match(/^[a-z_][a-z0-9_-]*$/i) && - !themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name); + errorMessage(name, themeFields) { + if (name) { + if (!name.match(/^[a-z_][a-z0-9_-]*$/i)) { + return I18n.t("admin.customize.theme.variable_name_error.invalid_syntax"); + } else if (SCSS_VARIABLE_NAMES.includes(name.toLowerCase())) { + return I18n.t("admin.customize.theme.variable_name_error.no_overwrite"); + } else if (themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name)) { + return I18n.t("admin.customize.theme.variable_name_error.must_be_unique"); + } + } + + return null; + }, + + @computed('errorMessage') + nameValid(errorMessage) { + return null === errorMessage; }, @observes('name') diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 index 5206f65186..2c22ab15c2 100644 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ b/app/assets/javascripts/admin/mixins/async-report.js.es6 @@ -1,68 +1,80 @@ -import computed from 'ember-addons/ember-computed-decorators'; -import Report from "admin/models/report"; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Mixin.create({ classNameBindings: ["isLoading"], - - report: null, + reports: null, + isLoading: false, + dataSourceNames: "", + title: null, init() { this._super(); + this.set("reports", []); + }, - this.messageBus.subscribe(this.get("dataSource"), report => { - const formatDate = (date) => moment(date).format("YYYYMMDD"); + @computed("dataSourceNames") + dataSources(dataSourceNames) { + return dataSourceNames.split(",").map(source => `/admin/reports/${source}`); + }, - // this check is done to avoid loading a chart after period has changed - if ( - (this.get("startDate") && formatDate(report.start_date) === formatDate(this.get("startDate"))) && - (this.get("endDate") && formatDate(report.end_date) === formatDate(this.get("endDate"))) - ) { - this._setPropertiesFromReport(Report.create(report)); - this.set("isLoading", false); - this.renderReport(); + @computed("reports.[]", "startDate", "endDate", "dataSourceNames") + reportsForPeriod(reports, startDate, endDate, dataSourceNames) { + // on a slow network fetchReport could be called multiple times between + // T and T+x, and all the ajax responses would occur after T+(x+y) + // to avoid any inconsistencies we filter by period and make sure + // the array contains only unique values + reports = reports.uniqBy("report_key"); + + + const sort = (r) => { + if (r.length > 1) { + return dataSourceNames + .split(",") + .map(name => r.findBy("type", name)); } else { - this._setPropertiesFromReport(Report.create(report)); - this.set("isLoading", false); - this.renderReport(); + return r; } - }); + }; + + if (!startDate || !endDate) { + return sort(reports); + } + + + return sort(reports.filter(report => { + return report.report_key.includes(startDate.format("YYYYMMDD")) && + report.report_key.includes(endDate.format("YYYYMMDD")); + })); }, didInsertElement() { this._super(); - Ember.run.later(this, function() { - this.fetchReport(); - }, 500); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, didUpdateAttrs() { this._super(); - this.fetchReport(); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, - renderReport() {}, - - @computed("dataSourceName") - dataSource(dataSourceName) { - return `/admin/reports/${dataSourceName}`; + renderReport() { + if (!this.element || this.isDestroying || this.isDestroyed) return; + this.set("title", this.get("reportsForPeriod").map(r => r.title).join(", ")); + this.set("isLoading", false); }, - @computed("report") - labels(report) { - if (!report) return; - return Ember.makeArray(report.data).map(r => r.x); - }, + loadReport() {}, - @computed("report") - values(report) { - if (!report) return; - return Ember.makeArray(report.data).map(r => r.y); + fetchReport() { + this.set("reports", []); + this.set("isLoading", true); }, - - _setPropertiesFromReport(report) { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - this.setProperties({ report }); - } }); diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 index 4747529fe7..db3adf2248 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 @@ -13,10 +13,13 @@ AdminDashboardNext.reopenClass({ @return {jqXHR} a jQuery Promise object **/ find() { + return ajax("/admin/dashboard-next.json").then(function(json) { + var model = AdminDashboardNext.create(); model.set("reports", json.reports); + model.set("version_check", json.version_check); const attributes = {}; ATTRIBUTES.forEach(a => attributes[a] = json[a]); @@ -24,6 +27,25 @@ AdminDashboardNext.reopenClass({ model.set("loaded", true); + return model; + }); + }, + + + /** + Only fetch the list of problems that should be rendered on the dashboard. + The model will only have its "problems" attribute set. + + @method fetchProblems + @return {jqXHR} a jQuery Promise object + **/ + fetchProblems: function() { + return ajax("/admin/dashboard/problems.json", { + type: 'GET', + dataType: 'json' + }).then(function(json) { + var model = AdminDashboardNext.create(json); + model.set('loaded', true); return model; }); } diff --git a/app/assets/javascripts/admin/models/admin-dashboard.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard.js.es6 index ac44a7677f..49ed8e1a07 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard.js.es6 @@ -19,23 +19,6 @@ AdminDashboard.reopenClass({ }); }, - /** - Only fetch the list of problems that should be rendered on the dashboard. - The model will only have its "problems" attribute set. - - @method fetchProblems - @return {jqXHR} a jQuery Promise object - **/ - fetchProblems: function() { - return ajax("/admin/dashboard/problems.json", { - type: 'GET', - dataType: 'json' - }).then(function(json) { - var model = AdminDashboard.create(json); - model.set('loaded', true); - return model; - }); - } }); export default AdminDashboard; diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 6cd414e976..b826426473 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -300,7 +300,8 @@ const AdminUser = Discourse.User.extend({ deactivate() { return ajax('/admin/users/' + this.id + '/deactivate', { - type: 'PUT' + type: 'PUT', + data: { context: document.location.pathname } }).then(function() { window.location.reload(); }).catch(function(e) { diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 1113f2e5d1..5441be004a 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -1,17 +1,22 @@ import { ajax } from 'discourse/lib/ajax'; import round from "discourse/lib/round"; -import { fmt } from 'discourse/lib/computed'; import { fillMissingDates } from 'discourse/lib/utilities'; import computed from 'ember-addons/ember-computed-decorators'; const Report = Discourse.Model.extend({ average: false, + percent: false, - reportUrl: fmt("type", "/admin/reports/%@"), + @computed("type", "start_date", "end_date") + reportUrl(type, start_date, end_date) { + start_date = moment(start_date).locale('en').format("YYYY-MM-DD"); + end_date = moment(end_date).locale('en').format("YYYY-MM-DD"); + return Discourse.getURL(`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`); + }, valueAt(numDaysAgo) { if (this.data) { - const wantedDate = moment().subtract(numDaysAgo, "days").format("YYYY-MM-DD"); + const wantedDate = moment().subtract(numDaysAgo, "days").locale('en').format("YYYY-MM-DD"); const item = this.data.find(d => d.x === wantedDate); if (item) { return item.y; @@ -90,6 +95,50 @@ const Report = Discourse.Model.extend({ } }, + @computed('data') + currentTotal(data){ + return _.reduce(data, (cur, pair) => cur + pair.y, 0); + }, + + @computed('data', 'currentTotal') + currentAverage(data, total) { + return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); + }, + + @computed("trend") + trendIcon(trend) { + switch (trend) { + case "trending-up": + return "angle-up"; + case "trending-down": + return "angle-down"; + case "high-trending-up": + return "angle-double-up"; + case "high-trending-down": + return "angle-double-down"; + default: + return null; + } + }, + + @computed('prev_period', 'currentTotal', 'currentAverage') + trend(prev, currentTotal, currentAverage) { + const total = this.get('average') ? currentAverage : currentTotal; + const change = ((total - prev) / total) * 100; + + if (change > 50) { + return "high-trending-up"; + } else if (change > 0) { + return "trending-up"; + } else if (change === 0) { + return "no-change"; + } else if (change < -50) { + return "high-trending-down"; + } else if (change < 0) { + return "trending-down"; + } + }, + @computed('prev30Days', 'lastThirtyDaysCount') thirtyDayTrend(prev30Days, lastThirtyDaysCount) { const currentPeriod = lastThirtyDaysCount; @@ -110,6 +159,9 @@ const Report = Discourse.Model.extend({ @computed('type') icon(type) { + if (type.indexOf("message") > -1) { + return "envelope"; + } switch (type) { case "flags": return "flag"; case "likes": return "heart"; @@ -138,6 +190,22 @@ const Report = Discourse.Model.extend({ } }, + @computed('prev_period', 'currentTotal', 'currentAverage') + trendTitle(prev, currentTotal, currentAverage) { + let current = this.get('average') ? currentAverage : currentTotal; + let percent = this.percentChangeString(current, prev); + + if (this.get('average')) { + prev = prev ? prev.toFixed(1) : "0"; + if (this.get('percent')) { + current += '%'; + prev += '%'; + } + } + + return I18n.t('admin.dashboard.reports.trend_title', {percent: percent, prev: prev, current: current}); + }, + changeTitle(val1, val2, prevPeriodString) { const percentChange = this.percentChangeString(val1, val2); var title = ""; @@ -176,6 +244,15 @@ const Report = Discourse.Model.extend({ Report.reopenClass({ + fillMissingDates(report) { + if (_.isArray(report.data)) { + + const startDateFormatted = moment.utc(report.start_date).locale('en').format('YYYY-MM-DD'); + const endDateFormatted = moment.utc(report.end_date).locale('en').format('YYYY-MM-DD'); + report.data = fillMissingDates(report.data, startDateFormatted, endDateFormatted); + } + }, + find(type, startDate, endDate, categoryId, groupId) { return ajax("/admin/reports/" + type, { data: { @@ -186,11 +263,7 @@ Report.reopenClass({ } }).then(json => { // Add zero values for missing dates - if (json.report.data.length > 0) { - const startDateFormatted = moment(json.report.start_date).utc().format('YYYY-MM-DD'); - const endDateFormatted = moment(json.report.end_date).utc().format('YYYY-MM-DD'); - json.report.data = fillMissingDates(json.report.data, startDateFormatted, endDateFormatted); - } + Report.fillMissingDates(json.report); const model = Report.create({ type: type }); model.setProperties(json.report); diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 index c877f07bf5..30ca9b033c 100644 --- a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 @@ -1,9 +1,5 @@ -import loadScript from "discourse/lib/load-script"; - export default Discourse.Route.extend({ activate() { - loadScript("/javascripts/Chart.min.js").then(() => { - this.controllerFor('admin-dashboard-next').fetchDashboard(); - }); + this.controllerFor('admin-dashboard-next').fetchDashboard(); } }); diff --git a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 index 820c5207d4..8c5886f4a4 100644 --- a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 @@ -8,12 +8,15 @@ export default Ember.Route.extend({ const controller = this.controllerFor('adminSiteSettings'); this.transitionTo('adminSiteSettingsCategory', 'plugins').then(() => { if (plugin) { + const siteSettingFilter = plugin.get('enabled_setting_filter'); const match = /^(.*)_enabled/.exec(plugin.get('enabled_setting')); - if (match[1]) { + const filter = siteSettingFilter || match[1]; + + if (filter) { // filterContent() is normally on a debounce from typing. // Because we don't want the default of "All Results", we tell it // to skip the next debounce. - controller.set('filter', match[1]); + controller.set('filter', filter); controller.set('_skipBounce', true); controller.filterContentNow('plugins'); } @@ -22,4 +25,3 @@ export default Ember.Route.extend({ } } }); - 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 5b42f045e1..5a033e1efa 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,7 +1,7 @@ export default function() { this.route('admin', { resetNamespace: true }, function() { - this.route('dashboard', { path: '/' }); - this.route('dashboardNext', { path: '/dashboard-next' }); + this.route('dashboard', { path: '/dashboard-old' }); + this.route('dashboardNext', { path: '/' }); this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() { this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} ); }); diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp b/app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl similarity index 58% rename from plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp rename to app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl index 7411da1944..23cc50b1f3 100644 Binary files a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp and b/app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl differ diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 092133b7c1..cf4d231d4e 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -3,7 +3,7 @@
{{/d-modal-body}} diff --git a/app/assets/javascripts/admin/templates/reports.hbs b/app/assets/javascripts/admin/templates/reports.hbs index 1a6b4bf047..eab2782425 100644 --- a/app/assets/javascripts/admin/templates/reports.hbs +++ b/app/assets/javascripts/admin/templates/reports.hbs @@ -1,5 +1,9 @@

{{model.title}}

+{{#if model.description}} +

{{model.description}}

+{{/if}} +
{{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}} {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}} diff --git a/app/assets/javascripts/admin/templates/site-settings-category.hbs b/app/assets/javascripts/admin/templates/site-settings-category.hbs index 1af19ed7b6..5f61241932 100644 --- a/app/assets/javascripts/admin/templates/site-settings-category.hbs +++ b/app/assets/javascripts/admin/templates/site-settings-category.hbs @@ -3,6 +3,9 @@ {{#each filteredContent as |setting|}} {{site-setting setting=setting}} {{/each}} + {{#if category.hasMore}} +

{{i18n 'admin.site_settings.more_than_30_results'}}

+ {{/if}} {{/d-section}} {{else}}
diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs index e3d9f4d875..9f455b01e9 100644 --- a/app/assets/javascripts/admin/templates/site-settings.hbs +++ b/app/assets/javascripts/admin/templates/site-settings.hbs @@ -18,9 +18,7 @@
  • {{#link-to 'adminSiteSettingsCategory' category.nameKey class=category.nameKey}} {{category.name}} - {{#if filtered}} - {{#if category.count}}({{category.count}}){{/if}} - {{/if}} + {{#if category.count}}({{category.count}}){{/if}} {{/link-to}}
  • {{/each}} diff --git a/app/assets/javascripts/admin/templates/users-list.hbs b/app/assets/javascripts/admin/templates/users-list.hbs index 645fc83c1f..c101a1a13b 100644 --- a/app/assets/javascripts/admin/templates/users-list.hbs +++ b/app/assets/javascripts/admin/templates/users-list.hbs @@ -10,6 +10,8 @@ {{nav-item route='adminUsersList.show' routeParam='suspended' label='admin.users.nav.suspended'}} {{nav-item route='adminUsersList.show' routeParam='silenced' label='admin.users.nav.silenced'}} {{nav-item route='adminUsersList.show' routeParam='suspect' label='admin.users.nav.suspect'}} + {{nav-item route='adminUsersList.show' routeParam='staged' label='admin.users.nav.staged'}} + {{nav-item route='groups' label='groups.index.title'}}
    diff --git a/app/assets/javascripts/admin/templates/version-checks.hbs b/app/assets/javascripts/admin/templates/version-checks.hbs index a8724b7e5a..abd5f4befd 100644 --- a/app/assets/javascripts/admin/templates/version-checks.hbs +++ b/app/assets/javascripts/admin/templates/version-checks.hbs @@ -1,73 +1,83 @@ -
    - - {{custom-html name="upgrade-header" versionCheck=versionCheck tagName="thead"}} -
    +
    +

    {{i18n 'admin.dashboard.version'}}

    +
    +
    - - - - - - - - - - - +
    +

    {{i18n 'admin.dashboard.installed_version'}}

    +

    {{dash-if-empty versionCheck.installed_describe}}

    +
    {{#if versionCheck.noCheckPerformed}} - - - +
    +

    {{i18n 'admin.dashboard.latest_version'}}

    +

    +
    +
    +
    + {{d-icon "frown-o"}} +
    +
    + {{i18n 'admin.dashboard.no_check_performed'}} +
    +
    {{else}} {{#if versionCheck.stale_data}} - - - - {{else}} - - - + + + {{else}} +
    +

    {{i18n 'admin.dashboard.latest_version'}}

    +

    {{dash-if-empty versionCheck.latest_version}}

    +
    +
    +
    + {{#if versionCheck.upToDate }} + {{d-icon "smile-o"}} + {{else}} + + {{#if versionCheck.behindByOneVersion}} + {{d-icon "meh-o"}} + {{else}} + {{d-icon "frown-o"}} + {{/if}} + + {{/if}} +
    +
    + {{#if versionCheck.upToDate }} + {{i18n 'admin.dashboard.up_to_date'}} + {{else}} + {{i18n 'admin.dashboard.critical_available'}} + {{i18n 'admin.dashboard.updates_available'}} + {{i18n 'admin.dashboard.please_upgrade'}} + {{/if}} +
    +
    + {{/if}} {{/if}} - -
     {{i18n 'admin.dashboard.installed_version'}}{{i18n 'admin.dashboard.latest_version'}}
    {{i18n 'admin.dashboard.version'}}{{dash-if-empty versionCheck.installed_describe}} - {{d-icon "frown-o"}} - - {{i18n 'admin.dashboard.no_check_performed'}} - {{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}} - {{#if versionCheck.version_check_pending}} - {{d-icon "smile-o"}} - {{else}} - {{d-icon "frown-o"}} - {{/if}} - - +
    +

    {{i18n 'admin.dashboard.latest_version'}}

    +

    {{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}}

    +
    +
    +
    {{#if versionCheck.version_check_pending}} - {{i18n 'admin.dashboard.version_check_pending'}} + {{d-icon "smile-o"}} {{else}} - {{i18n 'admin.dashboard.stale_data'}} + {{d-icon "frown-o"}} {{/if}} - -
    {{dash-if-empty versionCheck.latest_version}} - {{#if versionCheck.upToDate }} - {{d-icon "smile-o"}} - {{else}} - - {{#if versionCheck.behindByOneVersion}} - {{d-icon "meh-o"}} + +
    + + {{#if versionCheck.version_check_pending}} + {{i18n 'admin.dashboard.version_check_pending'}} {{else}} - {{d-icon "frown-o"}} + {{i18n 'admin.dashboard.stale_data'}} {{/if}} - {{/if}} -
    - {{#if versionCheck.upToDate }} - {{i18n 'admin.dashboard.up_to_date'}} - {{else}} - {{i18n 'admin.dashboard.critical_available'}} - {{i18n 'admin.dashboard.updates_available'}} - {{i18n 'admin.dashboard.please_upgrade'}} - {{/if}} -
    + + {{custom-html name="upgrade-header" versionCheck=versionCheck tagName="div" classNames="upgrade-header"}} +
    diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index a1888006db..e7e9db37b4 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -9,6 +9,8 @@ //= require ./deprecated // Stuff we need to load first +//= require ./discourse/helpers/parse-html +//= require ./discourse/lib/to-markdown //= require ./discourse/lib/utilities //= require ./discourse/lib/page-visible //= require ./discourse/lib/logout diff --git a/app/assets/javascripts/discourse/components/categories-boxes.js.es6 b/app/assets/javascripts/discourse/components/categories-boxes.js.es6 index fc63667da2..7f364b9d9e 100644 --- a/app/assets/javascripts/discourse/components/categories-boxes.js.es6 +++ b/app/assets/javascripts/discourse/components/categories-boxes.js.es6 @@ -1,4 +1,5 @@ import computed from 'ember-addons/ember-computed-decorators'; +import DiscourseURL from 'discourse/lib/url'; export default Ember.Component.extend({ tagName: "section", @@ -8,5 +9,14 @@ export default Ember.Component.extend({ anyLogos() { return this.get("categories").any((c) => { return !Ember.isEmpty(c.get('uploaded_logo.url')); }); return this.get("categories").any(c => !Ember.isEmpty(c.get('uploaded_logo.url'))); + }, + + click(e) { + if (!$(e.target).is('a')) { + const url = $(e.target).closest('.category-box').data("url"); + if (url) { + DiscourseURL.routeTo(url); + } + } } }); diff --git a/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 b/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 index 7e9323eb5e..dd8f321635 100644 --- a/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 +++ b/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 @@ -1,5 +1,3 @@ -import computed from 'ember-addons/ember-computed-decorators'; - export default Ember.Component.extend({ classNames: ["conditional-loading-section"], @@ -7,8 +5,5 @@ export default Ember.Component.extend({ isLoading: false, - @computed("title") - computedTitle(title) { - return title || I18n.t("conditional_loading_section.loading"); - } + title: I18n.t("conditional_loading_section.loading") }); diff --git a/app/assets/javascripts/discourse/components/date-picker.js.es6 b/app/assets/javascripts/discourse/components/date-picker.js.es6 index 709e1e5149..180c46c362 100644 --- a/app/assets/javascripts/discourse/components/date-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker.js.es6 @@ -36,6 +36,9 @@ export default Ember.Component.extend({ @on("willDestroyElement") _destroy() { + if (this._picker) { + this._picker.destroy(); + } this._picker = null; }, diff --git a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 index d1ad4f28ab..9b981b6b8f 100644 --- a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 +++ b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 @@ -1,6 +1,13 @@ import computed from 'ember-addons/ember-computed-decorators'; import KeyValueStore from 'discourse/lib/key-value-store'; -import { context } from 'discourse/lib/desktop-notifications'; +import { context, confirmNotification } from 'discourse/lib/desktop-notifications'; +import { + subscribe as subscribePushNotification, + unsubscribe as unsubscribePushNotification, + isPushNotificationsSupported, + keyValueStore as pushNotificationKeyValueStore, + userSubscriptionKey as pushNotificationUserSubscriptionKey +} from 'discourse/lib/push-notifications'; const keyValueStore = new KeyValueStore(context); @@ -28,11 +35,6 @@ export default Ember.Component.extend({ return typeof window.Notification === "undefined"; }, - @computed("isNotSupported", "notificationsPermission") - isDefaultPermission(isNotSupported, notificationsPermission) { - return isNotSupported ? false : notificationsPermission === "default"; - }, - @computed("isNotSupported", "notificationsPermission") isDeniedPermission(isNotSupported, notificationsPermission) { return isNotSupported ? false : notificationsPermission === "denied"; @@ -44,27 +46,65 @@ export default Ember.Component.extend({ }, @computed("isGrantedPermission", "notificationsDisabled") - isEnabled(isGrantedPermission, notificationsDisabled) { + isEnabledDesktop(isGrantedPermission, notificationsDisabled) { return isGrantedPermission ? !notificationsDisabled : false; }, - actions: { - requestPermission() { - Notification.requestPermission(() => this.propertyDidChange('notificationsPermission')); + @computed + isEnabledPush: { + set(value) { + const user = this.currentUser; + if(!user) { + return false; + } + pushNotificationKeyValueStore.setItem(pushNotificationUserSubscriptionKey(user), value); + return pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)); }, + get() { + const user = this.currentUser; + return user ? pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)) : false; + } + }, + isEnabled: Ember.computed.or("isEnabledDesktop", "isEnabledPush"), + + isPushNotificationsPreferred() { + if(!this.site.mobileView) { + return false; + } + return isPushNotificationsSupported(this.site.mobileView); + }, + + actions: { recheckPermission() { this.propertyDidChange('notificationsPermission'); }, turnoff() { - this.set('notificationsDisabled', 'disabled'); - this.propertyDidChange('notificationsPermission'); + if(this.get('isEnabledDesktop')) { + this.set('notificationsDisabled', 'disabled'); + this.propertyDidChange('notificationsPermission'); + } + if(this.get('isEnabledPush')) { + unsubscribePushNotification(this.currentUser, () => { + this.set("isEnabledPush", ''); + }); + } }, turnon() { - this.set('notificationsDisabled', ''); - this.propertyDidChange('notificationsPermission'); + if(this.isPushNotificationsPreferred()) { + subscribePushNotification(() => { + this.set("isEnabledPush", 'subscribed'); + }, this.siteSettings.vapid_public_key_bytes); + } + else { + this.set('notificationsDisabled', ''); + Notification.requestPermission(() => { + confirmNotification(); + this.propertyDidChange('notificationsPermission'); + }); + } } } }); diff --git a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 index 827e0460c6..0bf7f9f7bf 100644 --- a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 @@ -50,7 +50,7 @@ export default Ember.Component.extend({ this._positionPicker(); this._scrollTo(); this._updateSelectedDiversity(); - this._checkVisibleSection(); + this._checkVisibleSection(true); }); }, @@ -106,7 +106,7 @@ export default Ember.Component.extend({ } this._updateSelectedDiversity(); - this._checkVisibleSection(); + this._checkVisibleSection(true); }, @observes("recentEmojis") @@ -192,7 +192,7 @@ export default Ember.Component.extend({ _unbindEvents() { this.$().off(); this.$(window).off("resize"); - this.$modal.off("click"); + clearInterval(this._refreshInterval); $("#reply-control").off("div-resizing"); $('html').off("mouseup.emoji-picker"); }, @@ -316,18 +316,27 @@ export default Ember.Component.extend({ }, _bindSectionsScroll() { - this.$list.on("scroll", () => { - this.scrollPosition = this.$list.scrollTop(); + let onScroll = () => { run.debounce(this, this._checkVisibleSection, 50); - }); + }; + + this.$list.on("scroll", onScroll); + this._refreshInterval = setInterval(onScroll, 100); }, - _checkVisibleSection() { + _checkVisibleSection(force) { // make sure we stop loading if picker has been removed if(!this.$picker) { return; } + const newPosition = this.$list.scrollTop(); + if (newPosition === this.scrollPosition && !force) { + return; + } + + this.scrollPosition = newPosition; + const $sections = this.$list.find(".section"); const listHeight = this.$list.innerHeight(); let $selectedSection; @@ -523,19 +532,31 @@ export default Ember.Component.extend({ }, _setButtonBackground(button, diversity) { - const $button = $(button); - const code = this._codeWithDiversity( - $button.attr("title"), - diversity || $button.hasClass("diversity") - ); - // force visual reloading if needed - if($button.css("background-image") !== "none") { - $button.css("background-image", ""); + if (!button) { + return; } - $button - .attr("data-loaded", 1) - .css("background-image", `url("${emojiUrlFor(code)}")`); + const $button = $(button); + button = $button[0]; + + // changing style can force layout events + // this could slow down timers and lead to + // chrome delaying the request + window.requestAnimationFrame(() =>{ + const code = this._codeWithDiversity( + $button.attr("title"), + diversity || $button.hasClass("diversity") + ); + + // // force visual reloading if needed + if(button.style.backgroundImage !== "none") { + button.style.backgroundImage = ""; + } + + button.style.backgroundImage = `url("${emojiUrlFor(code)}")`; + $button.attr("data-loaded", 1); + }); + }, }); diff --git a/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 new file mode 100644 index 0000000000..2c2f5c1012 --- /dev/null +++ b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 @@ -0,0 +1,45 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +import { + keyValueStore as pushNotificationKeyValueStore +} from 'discourse/lib/push-notifications'; + +import { default as DesktopNotificationConfig } from 'discourse/components/desktop-notification-config'; + +const userDismissedPromptKey = "dismissed-prompt"; + +export default DesktopNotificationConfig.extend({ + @computed + bannerDismissed: { + set(value) { + pushNotificationKeyValueStore.setItem(userDismissedPromptKey, value); + return pushNotificationKeyValueStore.getItem(userDismissedPromptKey); + }, + get() { + return pushNotificationKeyValueStore.getItem(userDismissedPromptKey); + } + }, + + @computed("isNotSupported", "isEnabled", "bannerDismissed", "currentUser.reply_count", "currentUser.topic_count") + showNotificationPromptBanner(isNotSupported, isEnabled, bannerDismissed, replyCount, topicCount) { + return (this.siteSettings.push_notifications_prompt && + !isNotSupported && + this.currentUser && + replyCount + topicCount > 0 && + Notification.permission !== "denied" && + Notification.permission !== "granted" && + !isEnabled && + !bannerDismissed + ); + }, + + actions: { + turnon() { + this._super(); + this.set("bannerDismissed", true); + }, + dismiss() { + this.set("bannerDismissed", true); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/radio-button.js.es6 b/app/assets/javascripts/discourse/components/radio-button.js.es6 index 3004f94106..252f7d7ac4 100644 --- a/app/assets/javascripts/discourse/components/radio-button.js.es6 +++ b/app/assets/javascripts/discourse/components/radio-button.js.es6 @@ -5,8 +5,12 @@ export default Ember.Component.extend({ type : "radio", attributeBindings : ["name", "type", "value", "checked:checked", "disabled:disabled"], - click: function() { - this.set("selection", this.$().val()); + click() { + const value = this.$().val(); + if (this.get("selection") === value) { + this.set("selection", undefined); + } + this.set("selection", value); }, @computed('value', 'selection') diff --git a/app/assets/javascripts/discourse/components/tag-list.js.es6 b/app/assets/javascripts/discourse/components/tag-list.js.es6 index efa958d71a..decb1cea5d 100644 --- a/app/assets/javascripts/discourse/components/tag-list.js.es6 +++ b/app/assets/javascripts/discourse/components/tag-list.js.es6 @@ -1,22 +1,23 @@ +import computed from "ember-addons/ember-computed-decorators"; + export default Ember.Component.extend({ classNameBindings: [':tag-list', 'categoryClass'], isPrivateMessage: false, sortedTags: Ember.computed.sort('tags', 'sortProperties'), - title: function() { - if (this.get('titleKey')) { return I18n.t(this.get('titleKey')); } - }.property('titleKey'), + @computed("titleKey") + title(titleKey) { + return titleKey && I18n.t(titleKey); + }, - category: function() { - if (this.get('categoryId')) { - return Discourse.Category.findById(this.get('categoryId')); - } - }.property('categoryId'), + @computed("categoryId") + category(categoryId) { + return categoryId && Discourse.Category.findById(categoryId); + }, - categoryClass: function() { - if (this.get('category')) { - return "tag-list-" + this.get('category.fullSlug'); - } - }.property('category') + @computed("category.fullSlug") + categoryClass(slug) { + return slug && `tag-list-${slug}`; + } }); diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index 9b3528f32b..1ac854e9fa 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -157,21 +157,18 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U }, createAccount() { - const self = this, - attrs = this.getProperties('accountName', 'accountEmail', 'accountPassword', 'accountUsername', 'accountPasswordConfirm', 'accountChallenge'), - userFields = this.get('userFields'); + const attrs = this.getProperties('accountName', 'accountEmail', 'accountPassword', 'accountUsername', 'accountPasswordConfirm', 'accountChallenge'); + const userFields = this.get('userFields'); // Add the userfields to the data if (!Ember.isEmpty(userFields)) { attrs.userFields = {}; - userFields.forEach(function(f) { - attrs.userFields[f.get('field.id')] = f.get('value'); - }); + userFields.forEach(f => attrs.userFields[f.get('field.id')] = f.get('value')); } this.set('formSubmitted', true); - return Discourse.User.createAccount(attrs).then(function(result) { - self.set('isDeveloper', false); + return Discourse.User.createAccount(attrs).then(result => { + this.set('isDeveloper', false); if (result.success) { // Trigger the browser's password manager using the hidden static login form: const $hidden_login_form = $('#hidden-login-form'); @@ -180,24 +177,21 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U $hidden_login_form.find('input[name=redirect]').val(userPath('account-created')); $hidden_login_form.submit(); } else { - self.flash(result.message || I18n.t('create_account.failed'), 'error'); + this.flash(result.message || I18n.t('create_account.failed'), 'error'); if (result.is_developer) { - self.set('isDeveloper', true); + this.set('isDeveloper', true); } if (result.errors && result.errors.email && result.errors.email.length > 0 && result.values) { - self.get('rejectedEmails').pushObject(result.values.email); + this.get('rejectedEmails').pushObject(result.values.email); } if (result.errors && result.errors.password && result.errors.password.length > 0) { - self.get('rejectedPasswords').pushObject(attrs.accountPassword); + this.get('rejectedPasswords').pushObject(attrs.accountPassword); } - self.set('formSubmitted', false); + this.set('formSubmitted', false); } - if (result.active && !Discourse.SiteSettings.must_approve_users) { - return window.location.reload(); - } - }, function() { - self.set('formSubmitted', false); - return self.flash(I18n.t('create_account.failed'), 'error'); + }, () => { + this.set('formSubmitted', false); + return this.flash(I18n.t('create_account.failed'), 'error'); }); } } diff --git a/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 b/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 index e1a122a37a..c12ae43a59 100644 --- a/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 @@ -15,12 +15,15 @@ export default Ember.Controller.extend(ModalFunctionality, { @computed("model.closed") publicTimerTypes(closed) { - return [ + let types = [ { id: CLOSE_STATUS_TYPE, name: I18n.t(closed ? 'topic.temp_open.title' : 'topic.auto_close.title'), }, { id: OPEN_STATUS_TYPE, name: I18n.t(closed ? 'topic.auto_reopen.title' : 'topic.temp_close.title') }, { id: PUBLISH_TO_CATEGORY_STATUS_TYPE, name: I18n.t('topic.publish_to_category.title') }, - { id: DELETE_STATUS_TYPE, name: I18n.t('topic.auto_delete.title') } ]; + if (this.currentUser.get("staff")) { + types.push({ id: DELETE_STATUS_TYPE, name: I18n.t('topic.auto_delete.title') }); + } + return types; }, @computed() @@ -32,20 +35,12 @@ export default Ember.Controller.extend(ModalFunctionality, { @computed("isPublic", 'publicTimerTypes', 'privateTimerTypes') selections(isPublic, publicTimerTypes, privateTimerTypes) { - if (isPublic === 'true') { - return publicTimerTypes; - } else { - return privateTimerTypes; - } + return "true" === isPublic ? publicTimerTypes : privateTimerTypes; }, @computed('isPublic', 'model.topic_timer', 'model.private_topic_timer') topicTimer(isPublic, publicTopicTimer, privateTopicTimer) { - if (isPublic === 'true') { - return publicTopicTimer; - } else { - return privateTopicTimer; - } + return "true" === isPublic ? publicTopicTimer : privateTopicTimer; }, _setTimer(time, statusType) { diff --git a/app/assets/javascripts/discourse/controllers/group-index.js.es6 b/app/assets/javascripts/discourse/controllers/group-index.js.es6 index fffc78e0ac..c58b0a7184 100644 --- a/app/assets/javascripts/discourse/controllers/group-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-index.js.es6 @@ -14,6 +14,7 @@ export default Ember.Controller.extend({ showActions: false, filter: null, filterInput: null, + application: Ember.inject.controller(), @observes("filterInput") _setFilter: debounce(function() { @@ -27,7 +28,10 @@ export default Ember.Controller.extend({ if (model) { model.findMembers(this.get('memberParams')) - .finally(() => this.set('loading', false)); + .finally(() => { + this.set('application.showFooter', model.members.length >= model.user_count); + this.set('loading', false); + }); } }, @@ -81,7 +85,10 @@ export default Ember.Controller.extend({ loadMore() { if (this.get("loading")) { return; } - if (this.get("model.members.length") >= this.get("model.user_count")) { return; } + if (this.get("model.members.length") >= this.get("model.user_count")) { + this.set("application.showFooter", true); + return; + } this.set("loading", true); diff --git a/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 b/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 index 0019127c3a..bd6bb30472 100644 --- a/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 @@ -4,6 +4,7 @@ export default Ember.Controller.extend({ group: Ember.inject.controller(), loading: false, offset: 0, + application: Ember.inject.controller(), init() { this._super(); @@ -27,6 +28,11 @@ export default Ember.Controller.extend({ }); }, + @observes("model.all_loaded") + _showFooter() { + this.set("application.showFooter", this.get("model.all_loaded")); + }, + reset() { this.setProperties({ offset: 0, diff --git a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 index cce4fa05b1..acf1c58d46 100644 --- a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 @@ -1,7 +1,7 @@ import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ - sortProperties: ['count:desc', 'id'], + sortProperties: ['totalCount:desc', 'id'], sortedByCount: true, sortedByName: false, @@ -21,7 +21,7 @@ export default Ember.Controller.extend({ actions: { sortByCount() { this.setProperties({ - sortProperties: ['count:desc', 'id'], + sortProperties: ['totalCount:desc', 'id'], sortedByCount: true, sortedByName: false }); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 3a8782ab4a..46448d8447 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -131,7 +131,6 @@ export default Ember.Controller.extend(BufferedContent, { return this.get('model.postStream').loadPost(postId).then(post => { const composer = this.get('composer'); const viewOpen = composer.get('model.viewOpen'); - const quotedText = Quote.build(post, buffer); // If we can't create a post, delegate to reply as new topic diff --git a/app/assets/javascripts/discourse/controllers/users.js.es6 b/app/assets/javascripts/discourse/controllers/users.js.es6 index f5bb196302..cb2cafbc1f 100644 --- a/app/assets/javascripts/discourse/controllers/users.js.es6 +++ b/app/assets/javascripts/discourse/controllers/users.js.es6 @@ -2,11 +2,13 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ application: Ember.inject.controller(), - queryParams: ["period", "order", "asc", "name"], + queryParams: ["period", "order", "asc", "name", "group", "exclude_usernames"], period: "weekly", order: "likes_received", asc: null, name: "", + group: null, + exclude_usernames: null, showTimeRead: Ember.computed.equal("period", "all"), diff --git a/app/assets/javascripts/discourse/helpers/bound-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/bound-avatar.js.es6 index b8f04b1e6e..a284fb3671 100644 --- a/app/assets/javascripts/discourse/helpers/bound-avatar.js.es6 +++ b/app/assets/javascripts/discourse/helpers/bound-avatar.js.es6 @@ -8,6 +8,5 @@ export default htmlHelper((user, size) => { } const avatarTemplate = Ember.get(user, 'avatar_template'); - return avatarImg({ size, avatarTemplate }); return avatarImg(addExtraUserClasses(user, { size, avatarTemplate })); }); diff --git a/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 b/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 index d5439c7323..44b9d32325 100644 --- a/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 +++ b/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 @@ -1,7 +1,14 @@ import { htmlHelper } from 'discourse-common/lib/helpers'; import { iconHTML } from 'discourse-common/lib/icon-library'; -export default htmlHelper(function(str) { - if (Ember.isEmpty(str)) { return ""; } - return (str.indexOf('fa-') === 0) ? iconHTML(str.replace('fa-', '')) : ``; +export default htmlHelper(function({ icon, image }) { + if (!Ember.isEmpty(image)) { + return ``; + } + + if (Ember.isEmpty(icon) || icon.indexOf('fa-') !== 0) { + return ''; + } + + return iconHTML(icon.replace('fa-', '')); }); diff --git a/app/assets/javascripts/discourse/helpers/period-title.js.es6 b/app/assets/javascripts/discourse/helpers/period-title.js.es6 index 5a8315117d..d5d398791e 100644 --- a/app/assets/javascripts/discourse/helpers/period-title.js.es6 +++ b/app/assets/javascripts/discourse/helpers/period-title.js.es6 @@ -12,21 +12,29 @@ export default htmlHelper((period, options) => { const title = I18n.t('filters.top.' + (TITLE_SUBS[period] || 'this_week')); if (options.hash.showDateRange) { var dateString = ""; + let finish; + + if (options.hash.fullDay) { + finish = moment().utc().subtract(1, 'days'); + } else { + finish = moment(); + } + switch(period) { case 'yearly': - dateString = moment().subtract(1, 'year').format(I18n.t('dates.long_with_year_no_time')) + " - " + moment().format(I18n.t('dates.long_with_year_no_time')); + dateString = finish.clone().subtract(1, 'year').format(I18n.t('dates.long_with_year_no_time')) + " - " + finish.format(I18n.t('dates.long_with_year_no_time')); break; case 'quarterly': - dateString = moment().subtract(3, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time')); + dateString = finish.clone().subtract(3, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time')); break; case 'weekly': - dateString = moment().subtract(1, 'week').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time')); + dateString = finish.clone().subtract(1, 'week').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time')); break; case 'monthly': - dateString = moment().subtract(1, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time')); + dateString = finish.clone().subtract(1, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time')); break; case 'daily': - dateString = moment().format(I18n.t('dates.full_no_year_no_time')); + dateString = finish.clone().format(I18n.t('dates.full_no_year_no_time')); break; } diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index a477463d95..fd2c8ff418 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -2,8 +2,14 @@ import { init as initDesktopNotifications, onNotification, - alertChannel + alertChannel, + disable as disableDesktopNotifications, } from 'discourse/lib/desktop-notifications'; +import { + register as registerPushNotifications, + unsubscribe as unsubscribePushNotifications, + isPushNotificationsEnabled +} from 'discourse/lib/push-notifications'; export default { name: 'subscribe-user-notifications', @@ -11,14 +17,9 @@ export default { initialize(container) { const user = container.lookup('current-user:main'); - const keyValueStore = container.lookup('key-value-store:main'); const bus = container.lookup('message-bus:main'); const appEvents = container.lookup('app-events:main'); - // clear old cached notifications, we used to store in local storage - // TODO 2017 delete this line - keyValueStore.remove('recent-notifications'); - if (user) { if (user.get('staff')) { bus.subscribe('/flagged_counts', data => { @@ -87,6 +88,7 @@ export default { const site = container.lookup('site:main'); const siteSettings = container.lookup('site-settings:main'); + const router = container.lookup('router:main'); bus.subscribe("/categories", data => { _.each(data.categories, c => site.updateCategory(c)); @@ -100,9 +102,16 @@ export default { }); if (!Ember.testing) { - if (!site.mobileView) { - bus.subscribe(alertChannel(user), data => onNotification(data, user)); - initDesktopNotifications(bus, appEvents); + + bus.subscribe(alertChannel(user), data => onNotification(data, user)); + initDesktopNotifications(bus, appEvents); + + if(isPushNotificationsEnabled(user, site.mobileView)) { + disableDesktopNotifications(); + registerPushNotifications(Discourse.User.current(), site.mobileView, router, appEvents); + } + else { + unsubscribePushNotifications(user); } } } diff --git a/app/assets/javascripts/discourse/lib/binary-search.js.es6 b/app/assets/javascripts/discourse/lib/binary-search.js.es6 deleted file mode 100644 index 03675866e0..0000000000 --- a/app/assets/javascripts/discourse/lib/binary-search.js.es6 +++ /dev/null @@ -1,29 +0,0 @@ -// The binarySearch() function is licensed under the UNLICENSE -// https://github.com/Olical/binary-search - -// Modified for use in Discourse - -export default function binarySearch(list, target, keyProp) { - var min = 0; - var max = list.length - 1; - var guess; - var keyProperty = keyProp || "id"; - - while (min <= max) { - guess = Math.floor((min + max) / 2); - - if (Em.get(list[guess], keyProperty) === target) { - return guess; - } - else { - if (Em.get(list[guess], keyProperty) < target) { - min = guess + 1; - } - else { - max = guess - 1; - } - } - } - - return -Math.floor((min + max) / 2); -} diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 index 12c481e4af..d07442d24c 100644 --- a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -55,6 +55,22 @@ function init(messageBus, appEvents) { } } +function confirmNotification() { + const notification = new Notification(I18n.t("notifications.popup.confirm_title", {site_title: Discourse.SiteSettings.title}), { + body: I18n.t("notifications.popup.confirm_body"), + icon: Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url, + tag: "confirm-subscription" + }); + + const clickEventHandler = () => notification.close(); + + notification.addEventListener('click', clickEventHandler); + setTimeout(() => { + notification.close(); + notification.removeEventListener('click', clickEventHandler); + }, 10 * 1000); +} + // This function is only called if permission was granted function setupNotifications(appEvents) { @@ -167,4 +183,8 @@ function unsubscribe(bus, user) { bus.unsubscribe(alertChannel(user)); } -export { context, init, onNotification, unsubscribe, alertChannel }; +function disable() { + keyValueStore.setItem('notifications-disabled', 'disabled'); +} + +export { context, init, onNotification, unsubscribe, alertChannel, confirmNotification, disable }; diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 44b1437860..ce78480770 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -27,7 +27,7 @@ import { registerCustomAvatarHelper } from 'discourse/helpers/user-avatar'; import { disableNameSuppression } from 'discourse/widgets/poster-name'; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = '0.8.20'; +const PLUGIN_API_VERSION = '0.8.21'; class PluginApi { constructor(version, container) { @@ -44,20 +44,7 @@ class PluginApi { return this.container.lookup('current-user:main'); } - /** - * Allows you to overwrite or extend methods in a class. - * - * For example: - * - * ``` - * api.modifyClass('controller:composer', { - * actions: { - * newActionHere() { } - * } - * }); - * ``` - **/ - modifyClass(resolverName, changes, opts) { + _resolveClass(resolverName, opts) { opts = opts || {}; if (this.container.cache[resolverName]) { @@ -72,7 +59,48 @@ class PluginApi { return; } - klass.class.reopen(changes); + return klass; + } + + /** + * Allows you to overwrite or extend methods in a class. + * + * For example: + * + * ``` + * api.modifyClass('controller:composer', { + * actions: { + * newActionHere() { } + * } + * }); + * ``` + **/ + modifyClass(resolverName, changes, opts) { + + const klass = this._resolveClass(resolverName, opts); + if (klass) { + klass.class.reopen(changes); + } + return klass; + } + + /** + * Allows you to overwrite or extend static methods in a class. + * + * For example: + * + * ``` + * api.modifyClassStatic('controller:composer', { + * superFinder: function() { return []; } + * }); + * ``` + **/ + modifyClassStatic(resolverName, changes, opts) { + + const klass = this._resolveClass(resolverName, opts); + if (klass) { + klass.class.reopenClass(changes); + } return klass; } @@ -723,7 +751,12 @@ export function withPluginApi(version, apiCodeCallback, opts) { let _decorateId = 0; function decorate(klass, evt, cb) { const mixin = {}; - mixin["_decorate_" + (_decorateId++)] = function($elem) { cb($elem); }.on(evt); + mixin["_decorate_" + (_decorateId++)] = function($elem) { + $elem = $elem || this.$(); + if ($elem) { + cb($elem); + } + }.on(evt); klass.reopen(mixin); } diff --git a/app/assets/javascripts/discourse/lib/push-notifications.js.es6 b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 new file mode 100644 index 0000000000..53976b1cba --- /dev/null +++ b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 @@ -0,0 +1,119 @@ +import { ajax } from 'discourse/lib/ajax'; +import KeyValueStore from 'discourse/lib/key-value-store'; + +export const keyValueStore = new KeyValueStore("discourse_push_notifications_"); + +export function userSubscriptionKey(user) { + return `subscribed-${user.get('id')}`; +} + +function sendSubscriptionToServer(subscription, sendConfirmation) { + ajax('/push_notifications/subscribe', { + type: 'POST', + data: { subscription: subscription.toJSON(), send_confirmation: sendConfirmation } + }); +} + +function userAgentVersionChecker(agent, version, mobileView) { + const uaMatch = navigator.userAgent.match(new RegExp(`${agent}\/(\\d+)\\.\\d`)); + if (uaMatch && mobileView) return false; + if (!uaMatch || parseInt(uaMatch[1]) < version) return false; + return true; +} + +function resetIdle() { + if('controller' in navigator.serviceWorker && navigator.serviceWorker.controller != null) { + navigator.serviceWorker.controller.postMessage({lastAction: Date.now()}); + } +} + +function setupActivityListeners(appEvents) { + window.addEventListener("focus", resetIdle); + + if (document) { + document.addEventListener("scroll", resetIdle); + } + + appEvents.on('page:changed', resetIdle); +} + +export function isPushNotificationsSupported(mobileView) { + if (!(('serviceWorker' in navigator) && + (ServiceWorkerRegistration && + (typeof(Notification) !== "undefined") && + ('showNotification' in ServiceWorkerRegistration.prototype) && + ('PushManager' in window)))) { + + return false; + } + + if ((!userAgentVersionChecker('Firefox', 44, mobileView)) && + (!userAgentVersionChecker('Chrome', 50))) { + return false; + } + + return true; +} + +export function isPushNotificationsEnabled(user, mobileView) { + return user && isPushNotificationsSupported(mobileView) && keyValueStore.getItem(userSubscriptionKey(user)); +} + +export function register(user, mobileView, router, appEvents) { + if (!isPushNotificationsSupported(mobileView)) return; + if (Notification.permission === 'denied' || !user) return; + + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.getSubscription().then(subscription => { + if (subscription) { + sendSubscriptionToServer(subscription, false); + // Resync localStorage + keyValueStore.setItem(userSubscriptionKey(user), 'subscribed'); + } + setupActivityListeners(appEvents); + }).catch(e => Ember.Logger.error(e)); + }); + + navigator.serviceWorker.addEventListener('message', (event) => { + if ('url' in event.data) { + const url = event.data.url; + router.handleURL(url); + } + }); +} + +export function subscribe(callback, applicationServerKey, mobileView) { + if (!isPushNotificationsSupported(mobileView)) return; + + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: new Uint8Array(applicationServerKey.split("|")) // eslint-disable-line no-undef + }).then(subscription => { + sendSubscriptionToServer(subscription, true); + if (callback) callback(); + }).catch(e => Ember.Logger.error(e)); + }); +} + +export function unsubscribe(user, callback, mobileView) { + if (!isPushNotificationsSupported(mobileView)) return; + + keyValueStore.setItem(userSubscriptionKey(user), ''); + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.getSubscription().then(subscription => { + if (subscription) { + subscription.unsubscribe().then((successful) => { + if (successful) { + ajax('/push_notifications/unsubscribe', { + type: 'POST', + data: { subscription: subscription.toJSON() } + }); + } + }); + } + }).catch(e => Ember.Logger.error(e)); + + if (callback) callback(); + }); +} diff --git a/app/assets/javascripts/discourse/lib/quote.js.es6 b/app/assets/javascripts/discourse/lib/quote.js.es6 index bf1d290a5f..8dee880981 100644 --- a/app/assets/javascripts/discourse/lib/quote.js.es6 +++ b/app/assets/javascripts/discourse/lib/quote.js.es6 @@ -4,41 +4,32 @@ export default { // Build the BBCode quote around the selected text build(post, contents, opts) { - var contents_hashed, result, sansQuotes, stripped, stripped_hashed, tmp; - var full = opts && opts["full"]; - var raw = opts && opts["raw"]; - if (!post) { return ""; } if (!contents) contents = ""; - sansQuotes = contents.replace(this.REGEXP, '').trim(); + const sansQuotes = contents.replace(this.REGEXP, "").trim(); if (sansQuotes.length === 0) { return ""; } - // Escape the content of the quote - sansQuotes = sansQuotes.replace(//g, ">"); + // Strip the HTML from cooked + const stripped = $("
    ").html(post.get("cooked")).text(); - result = "[quote=\"" + post.get('username') + ", post:" + post.get('post_number') + ", topic:" + post.get('topic_id'); + // Let's remove any non-word characters as a kind of hash. + // Yes it's not accurate but it should work almost every time we need it to. + // It would be unlikely that the user would quote another post that matches in exactly this way. + const sameContent = stripped.replace(/\W/g, "") === contents.replace(/\W/g, ""); - /* Strip the HTML from cooked */ - tmp = document.createElement('div'); - tmp.innerHTML = post.get('cooked'); - stripped = tmp.textContent || tmp.innerText || ""; + const params = [ + post.get("username"), + `post:${post.get("post_number")}`, + `topic:${post.get("topic_id")}`, + ]; - /* - Let's remove any non alphanumeric characters as a kind of hash. Yes it's - not accurate but it should work almost every time we need it to. It would be unlikely - that the user would quote another post that matches in exactly this way. - */ - stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, ''); - contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, ''); + opts = opts || {}; - /* If the quote is the full message, attribute it as such */ - if (full || stripped_hashed === contents_hashed) result += ", full:true"; - result += "\"]\n" + (raw ? contents : sansQuotes) + "\n[/quote]\n\n"; + if (opts["full"] || sameContent) params.push("full:true"); - return result; + return `[quote="${params.join(", ")}"]\n${opts["raw"] ? contents : sansQuotes}\n[/quote]\n\n`; } }; diff --git a/app/assets/javascripts/discourse/lib/to-markdown.js.es6 b/app/assets/javascripts/discourse/lib/to-markdown.js.es6 index 692d4350ed..fa8259b7df 100644 --- a/app/assets/javascripts/discourse/lib/to-markdown.js.es6 +++ b/app/assets/javascripts/discourse/lib/to-markdown.js.es6 @@ -126,6 +126,14 @@ class Tag { decorate(text) { const attr = this.element.attributes; + if (/^mention/.test(attr.class) && "@" === text[0]) { + return text; + } + + if ("hashtag" === attr.class && "#" === text[0]) { + return text; + } + if (attr.href && text !== attr.href) { text = text.replace(/\n{2,}/g, "\n"); return "[" + text + "](" + attr.href + ")"; @@ -351,7 +359,7 @@ const tags = [ ...Tag.emphases().map((e) => Tag.emphasis(e[0], e[1])), Tag.cell("td"), Tag.cell("th"), Tag.replace("br", "\n"), Tag.replace("hr", "\n---\n"), Tag.replace("head", ""), - Tag.keep("ins"), Tag.keep("del"), Tag.keep("small"), Tag.keep("big"), + Tag.keep("ins"), Tag.keep("del"), Tag.keep("small"), Tag.keep("big"), Tag.keep("kbd"), Tag.li(), Tag.link(), Tag.image(), Tag.code(), Tag.blockquote(), Tag.table(), Tag.tr(), Tag.ol(), Tag.list("ul"), ]; diff --git a/app/assets/javascripts/discourse/lib/tooltip.js.es6 b/app/assets/javascripts/discourse/lib/tooltip.js.es6 new file mode 100644 index 0000000000..f1df4b3c9e --- /dev/null +++ b/app/assets/javascripts/discourse/lib/tooltip.js.es6 @@ -0,0 +1,73 @@ +export function showTooltip() { + const fadeSpeed = 300; + const tooltipID = "#discourse-tooltip"; + const $this = $(this); + const $parent = $this.offsetParent(); + const content = $this.attr("data-tooltip"); + const retina = window.devicePixelRatio && window.devicePixelRatio > 1 ? "class='retina'" : ""; + + let pos = $this.offset(); + const delta = $parent.offset(); + pos.top -= delta.top; + pos.left -= delta.left; + + $(tooltipID).fadeOut(fadeSpeed).remove(); + + $(this).after(` +
    +
    +
    ${content}
    +
    + `); + + $(window).on("click.discourse", (event) => { + if ($(event.target).closest(tooltipID).length === 0) { + $(tooltipID).remove(); + $(window).off("click.discourse"); + } + return true; + }); + + const $tooltip = $(tooltipID); + $tooltip.css({top: 0, left: 0}); + + let left = (pos.left - ($tooltip.width() / 2) + $this.width()/2); + if (left < 0) { + $tooltip.find(".tooltip-pointer").css({ + "margin-left": left*2 + "px" + }); + left = 0; + } + + // also do a right margin fix + const parentWidth = $parent.width(); + if (left + $tooltip.width() > parentWidth) { + let oldLeft = left; + left = parentWidth - $tooltip.width(); + + $tooltip.find(".tooltip-pointer").css({ + "margin-left": (oldLeft - left) * 2 + "px" + }); + } + + $tooltip.css({ + top: pos.top + 5 + "px", + left: left + "px" + }); + + $tooltip.fadeIn(fadeSpeed); + + return false; +} + +export function registerTooltip(jqueryContext) { + if (jqueryContext.length) { + jqueryContext.on('click', showTooltip); + } +} + +export function unregisterTooltip(jqueryContext) { + if (jqueryContext.length) { + jqueryContext.off('click'); + } +} diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index a6349e2fa0..84edc4e4d6 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -1,5 +1,6 @@ import { CANCELLED_STATUS } from 'discourse/lib/autocomplete'; import { userPath } from 'discourse/lib/url'; +import { emailValid } from 'discourse/lib/utilities'; var cache = {}, cacheTopicId, @@ -61,7 +62,7 @@ function organizeResults(r, options) { }); } - if (!options.disallowEmails && options.term.match(/@/)) { + if (!options.disallowEmails && emailValid(options.term)) { let e = { username: options.term }; emails = [ e ]; results.push(e); diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 987e868247..91a48e0de6 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -1,4 +1,5 @@ import { escape } from 'pretty-text/sanitizer'; +import toMarkdown from 'discourse/lib/to-markdown'; const homepageSelector = 'meta[name=discourse_current_homepage]'; @@ -113,12 +114,8 @@ export function selectedText() { $div.find(".clicks").remove(); // replace emojis $div.find("img.emoji").replaceWith(function() { return this.title; }); - // replace br with newlines - $div.find("br").replaceWith(() => "\n"); - // enforce newline at the end of paragraphs - $div.find("p").append(() => "\n"); - return String($div.text()).trim().replace(/(^\s*\n)+/gm, "\n"); + return toMarkdown($div.html()); } // Determine the row and col of the caret in an element @@ -497,7 +494,7 @@ export function fillMissingDates(data, startDate, endDate) { for (let i = 0; i <= countDays; i++) { let date = (data[i]) ? moment(data[i].x, "YYYY-MM-DD") : null; - if (i === 0 && date.isAfter(startMoment)) { + if (i === 0 && (!date || date.isAfter(startMoment))) { data.splice(i, 0, { "x" : startMoment.format("YYYY-MM-DD"), 'y': 0 }); } else { if (!date || date.isAfter(moment(currentMoment))) { diff --git a/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 index f05fcb55a6..0ff1193368 100644 --- a/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 +++ b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 @@ -1,7 +1,6 @@ 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} @@ -28,7 +27,7 @@ export default Ember.Mixin.create({ // Don't show on mobile if (this.site.mobileView) { - DiscourseURL.routeTo(userPath(username)); + DiscourseURL.routeTo($target.attr("href")); return false; } @@ -38,10 +37,10 @@ export default Ember.Mixin.create({ } 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; } diff --git a/app/assets/javascripts/discourse/models/category-list.js.es6 b/app/assets/javascripts/discourse/models/category-list.js.es6 index 987e502053..9511c49c58 100644 --- a/app/assets/javascripts/discourse/models/category-list.js.es6 +++ b/app/assets/javascripts/discourse/models/category-list.js.es6 @@ -37,7 +37,6 @@ CategoryList.reopenClass({ c.topics = c.topics.map(t => Discourse.Topic.create(t)); } - switch(statPeriod) { case "week": case "month": diff --git a/app/assets/javascripts/discourse/models/tag-group.js.es6 b/app/assets/javascripts/discourse/models/tag-group.js.es6 index 020f2b9901..ea29f41412 100644 --- a/app/assets/javascripts/discourse/models/tag-group.js.es6 +++ b/app/assets/javascripts/discourse/models/tag-group.js.es6 @@ -1,71 +1,62 @@ -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'; +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') - disableSave() { - return Ember.isEmpty(this.get('name')) || Ember.isEmpty(this.get('tag_names')) || this.get('saving'); +export default RestModel.extend({ + @computed("name", "tag_names", "saving") + disableSave(name, tagNames, saving) { + return saving || Ember.isEmpty(name) || Ember.isEmpty(tagNames); }, - @computed('permissions') + @computed("permissions") permissionName: { get(permissions) { - if (!permissions) return 'public'; + if (!permissions) return "public"; - if (permissions['everyone'] === PermissionType.FULL) { - return 'public'; - } else if (permissions['everyone'] === PermissionType.READONLY) { - return 'visible'; + if (permissions["everyone"] === PermissionType.FULL) { + return "public"; + } else if (permissions["everyone"] === PermissionType.READONLY) { + return "visible"; } else { - return 'private'; + 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}); + 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}); + this.set("permissions", { "everyone": PermissionType.FULL }); } } }, save() { - let url = "/tag_groups"; - const self = this, - isNew = this.get('id') === 'new'; - if (!isNew) { - url = "/tag_groups/" + this.get('id'); - } + this.set("savingStatus", I18n.t("saving")); + this.set("saving", true); - this.set('savingStatus', I18n.t('saving')); - this.set('saving', true); + const isNew = this.get("id") === "new"; + const url = isNew ? "/tag_groups" : `/tag_groups/${this.get("id")}`; + const data = this.getProperties("name", "tag_names", "parent_tag_name", "one_per_topic", "permissions"); return ajax(url, { - data: { - name: this.get('name'), - 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('permissions') - }, - type: isNew ? 'POST' : 'PUT' - }).then(function(result) { - if(result.tag_group && result.tag_group.id) { - self.set('id', result.tag_group.id); + data, + type: isNew ? "POST" : "PUT" + }).then(result => { + if (result.tag_group && result.tag_group.id) { + this.set("id", result.tag_group.id); } - self.set('savingStatus', I18n.t('saved')); - self.set('saving', false); + }).finally(() => { + this.set("savingStatus", I18n.t("saved")); + this.set("saving", false); }); }, destroy() { - return ajax("/tag_groups/" + this.get('id'), {type: "DELETE"}); + return ajax(`/tag_groups/${this.get("id")}`, { type: "DELETE" }); } }); -export default TagGroup; diff --git a/app/assets/javascripts/discourse/models/tag.js.es6 b/app/assets/javascripts/discourse/models/tag.js.es6 new file mode 100644 index 0000000000..572463e5bd --- /dev/null +++ b/app/assets/javascripts/discourse/models/tag.js.es6 @@ -0,0 +1,9 @@ +import RestModel from "discourse/models/rest"; +import computed from "ember-addons/ember-computed-decorators"; + +export default RestModel.extend({ + @computed("count", "pm_count") + totalCount(count, pmCount) { + return count + pmCount; + } +}); diff --git a/app/assets/javascripts/discourse/routes/group-manage-index.js.es6 b/app/assets/javascripts/discourse/routes/group-manage-index.js.es6 index f5b26ccd0c..590722bb0f 100644 --- a/app/assets/javascripts/discourse/routes/group-manage-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-manage-index.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + beforeModel() { this.transitionTo("group.manage.profile"); } diff --git a/app/assets/javascripts/discourse/routes/group-manage-interaction.js.es6 b/app/assets/javascripts/discourse/routes/group-manage-interaction.js.es6 index 7d0d44b6b8..313fcda642 100644 --- a/app/assets/javascripts/discourse/routes/group-manage-interaction.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-manage-interaction.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + titleToken() { return I18n.t('groups.manage.interaction.title'); }, diff --git a/app/assets/javascripts/discourse/routes/group-manage-membership.js.es6 b/app/assets/javascripts/discourse/routes/group-manage-membership.js.es6 index eb47fec87d..7689197d96 100644 --- a/app/assets/javascripts/discourse/routes/group-manage-membership.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-manage-membership.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + titleToken() { return I18n.t('groups.manage.membership.title'); }, diff --git a/app/assets/javascripts/discourse/routes/group-manage-profile.js.es6 b/app/assets/javascripts/discourse/routes/group-manage-profile.js.es6 index 7886d867be..e0c37246e1 100644 --- a/app/assets/javascripts/discourse/routes/group-manage-profile.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-manage-profile.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + titleToken() { return I18n.t('groups.manage.profile.title'); }, diff --git a/app/assets/javascripts/discourse/routes/group-manage.js.es6 b/app/assets/javascripts/discourse/routes/group-manage.js.es6 index e80cab1089..e7af6740ed 100644 --- a/app/assets/javascripts/discourse/routes/group-manage.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-manage.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + titleToken() { return I18n.t('groups.manage.title'); }, diff --git a/app/assets/javascripts/discourse/routes/groups-new.js.es6 b/app/assets/javascripts/discourse/routes/groups-new.js.es6 index 24d0e96858..e5a6fdd03c 100644 --- a/app/assets/javascripts/discourse/routes/groups-new.js.es6 +++ b/app/assets/javascripts/discourse/routes/groups-new.js.es6 @@ -1,6 +1,8 @@ import Group from 'discourse/models/group'; export default Discourse.Route.extend({ + showFooter: true, + titleToken() { return I18n.t('admin.groups.new.title'); }, diff --git a/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6 b/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6 index 0d67542b2f..b91bbf9aa0 100644 --- a/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + model(params) { return this.store.find('tagGroup', params.id); } diff --git a/app/assets/javascripts/discourse/routes/tag-groups.js.es6 b/app/assets/javascripts/discourse/routes/tag-groups.js.es6 index 6d6476964a..fb983936cf 100644 --- a/app/assets/javascripts/discourse/routes/tag-groups.js.es6 +++ b/app/assets/javascripts/discourse/routes/tag-groups.js.es6 @@ -1,4 +1,6 @@ export default Discourse.Route.extend({ + showFooter: true, + model() { return this.store.findAll('tagGroup'); }, diff --git a/app/assets/javascripts/discourse/routes/tags-index.js.es6 b/app/assets/javascripts/discourse/routes/tags-index.js.es6 index dcbe6f1929..7b7bffedb9 100644 --- a/app/assets/javascripts/discourse/routes/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/tags-index.js.es6 @@ -1,6 +1,22 @@ +import Tag from "discourse/models/tag"; + export default Discourse.Route.extend({ model() { - return this.store.findAll('tag'); + return this.store.findAll("tag").then(result => { + if (result.extras) { + if (result.extras.categories) { + result.extras.categories.forEach(category => { + category.tags = category.tags.map(t => Tag.create(t)); + }); + } + if (result.extras.tag_groups) { + result.extras.tag_groups.forEach(tagGroup => { + tagGroup.tags = tagGroup.tags.map(t => Tag.create(t)); + }); + } + } + return result; + }); }, titleToken() { @@ -10,7 +26,7 @@ export default Discourse.Route.extend({ setupController(controller, model) { this.controllerFor('tags.index').setProperties({ model, - sortProperties: this.siteSettings.tags_sort_alphabetically ? ['id'] : ['count:desc', 'id'] + sortProperties: this.siteSettings.tags_sort_alphabetically ? ['id'] : ['totalCount:desc', 'id'] }); }, diff --git a/app/assets/javascripts/discourse/routes/users.js.es6 b/app/assets/javascripts/discourse/routes/users.js.es6 index ab3cbe3206..9cb825c0f4 100644 --- a/app/assets/javascripts/discourse/routes/users.js.es6 +++ b/app/assets/javascripts/discourse/routes/users.js.es6 @@ -3,7 +3,9 @@ export default Discourse.Route.extend({ period: { refreshModel: true }, order: { refreshModel: true }, asc: { refreshModel: true }, - name: { refreshModel: true, replace: true } + name: { refreshModel: true, replace: true }, + group: { refreshModel: true }, + exclude_usernames: { refreshModel: true } }, refreshQueryWithoutTransition: true, @@ -18,7 +20,9 @@ export default Discourse.Route.extend({ period: "weekly", order: "likes_received", asc: null, - name: "" + name: "", + group: null, + exclude_usernames: null }); } }, diff --git a/app/assets/javascripts/discourse/templates/application.hbs b/app/assets/javascripts/discourse/templates/application.hbs index 6063284f8e..c75d0087c1 100644 --- a/app/assets/javascripts/discourse/templates/application.hbs +++ b/app/assets/javascripts/discourse/templates/application.hbs @@ -14,6 +14,7 @@ {{#if showTop}} {{custom-html name="top"}} {{/if}} + {{notification-consent-banner}} {{global-notice}} {{create-topics-notice}}
    diff --git a/app/assets/javascripts/discourse/templates/components/badge-button.hbs b/app/assets/javascripts/discourse/templates/components/badge-button.hbs index 8116e5195b..4dd20f5cdb 100644 --- a/app/assets/javascripts/discourse/templates/components/badge-button.hbs +++ b/app/assets/javascripts/discourse/templates/components/badge-button.hbs @@ -1,3 +1,3 @@ -{{icon-or-image badge.icon}} +{{icon-or-image badge}} {{badge.name}} {{yield}} diff --git a/app/assets/javascripts/discourse/templates/components/badge-card.hbs b/app/assets/javascripts/discourse/templates/components/badge-card.hbs index e211aaec06..4d30b96976 100644 --- a/app/assets/javascripts/discourse/templates/components/badge-card.hbs +++ b/app/assets/javascripts/discourse/templates/components/badge-card.hbs @@ -7,7 +7,7 @@
    - {{icon-or-image badge.icon}} + {{icon-or-image badge}}
    diff --git a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs index c473668648..0c90a54a03 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs @@ -1,24 +1,22 @@ {{#each categories as |c|}} -
    + {{/each}} diff --git a/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs b/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs index 2cc7599b98..b97ce25a01 100644 --- a/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs +++ b/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs @@ -1,5 +1,5 @@ {{#if isLoading}} - {{computedTitle}} + {{title}}
    {{else}} {{yield}} diff --git a/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs b/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs index dc8edd2018..d1ffffbdee 100644 --- a/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs +++ b/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs @@ -1,15 +1,10 @@ - {{#if isNotSupported}} {{d-button icon="bell-slash" label="user.desktop_notifications.not_supported" disabled="true"}} {{/if}} -{{#if isDefaultPermission}} - {{d-button icon="bell-slash" label="user.desktop_notifications.perm_default" action="requestPermission"}} -{{/if}} {{#if isDeniedPermission}} {{d-button icon="bell-slash" label="user.desktop_notifications.perm_denied_btn" action="recheckPermission" disabled='true'}} {{i18n "user.desktop_notifications.perm_denied_expl"}} -{{/if}} -{{#if isGrantedPermission}} +{{else}} {{#if isEnabled}} {{d-button icon="bell-slash-o" label="user.desktop_notifications.disable" action="turnoff"}} {{i18n "user.desktop_notifications.currently_enabled"}} diff --git a/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs index 4ae2f03a3d..b458965cec 100644 --- a/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs @@ -37,9 +37,26 @@ {{/if}}
    + {{#if group.bio_cooked}}
    {{{group.bio_cooked}}}
    {{/if}} + + {{#if group.members}} + {{/if}}
    {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs b/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs new file mode 100644 index 0000000000..f258abf113 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs @@ -0,0 +1,8 @@ +{{#if showNotificationPromptBanner}} +
    + +
    +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/tag-list.hbs b/app/assets/javascripts/discourse/templates/components/tag-list.hbs index cd09db7bc3..7be97e2439 100644 --- a/app/assets/javascripts/discourse/templates/components/tag-list.hbs +++ b/app/assets/javascripts/discourse/templates/components/tag-list.hbs @@ -9,11 +9,7 @@ {{/if}} {{#each sortedTags as |tag|}}
    - {{#if tag.count}} - {{discourse-tag tag.id isPrivateMessage=isPrivateMessage tagsForUser=tagsForUser}} x {{tag.count}} - {{else}} - {{discourse-tag tag.id}} - {{/if}} + {{discourse-tag tag.id isPrivateMessage=isPrivateMessage tagsForUser=tagsForUser}} {{#if tag.pm_count}}{{d-icon "envelope"}}{{/if}}{{#if tag.totalCount}} x {{tag.totalCount}}{{/if}}
    {{/each}}
    diff --git a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs index 92f8e832a4..66275c6634 100644 --- a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs +++ b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs @@ -156,6 +156,7 @@ {{/if}} {{#if showBadges}} + {{#if user.featured_user_badges}}
    {{#each user.featured_user_badges as |ub|}} {{user-badge badge=ub.badge user=user}} @@ -166,6 +167,7 @@ {{/link-to}} {{/if}}
    + {{/if}} {{/if}}
    {{/if}} diff --git a/app/assets/javascripts/discourse/templates/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/discovery/topics.hbs index 91bee5fb4a..acbb3cdcdb 100644 --- a/app/assets/javascripts/discourse/templates/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/discovery/topics.hbs @@ -76,12 +76,12 @@ {{#footer-message education=footerEducation message=footerMessage}} {{#if latest}} - {{#if canCreateTopicOnCategory}}{{i18n 'topic.suggest_create_topic'}}{{/if}} + {{#if canCreateTopicOnCategory}}{{i18n 'topic.suggest_create_topic'}}.{{/if}} {{else if top}} - {{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}} + {{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}. {{top-period-buttons period=period action="changePeriod"}} {{else}} - {{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} + {{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}. {{/if}} {{/footer-message}} diff --git a/app/assets/javascripts/discourse/templates/group-activity-posts.hbs b/app/assets/javascripts/discourse/templates/group-activity-posts.hbs index 5224c15ffb..8aea9bf7c4 100644 --- a/app/assets/javascripts/discourse/templates/group-activity-posts.hbs +++ b/app/assets/javascripts/discourse/templates/group-activity-posts.hbs @@ -1,11 +1,11 @@ -{{#load-more selector=".group-post" action=(action "loadMore")}} -
    - {{#each model as |post|}} - {{group-post post=post class="user-stream-item item"}} - {{else}} -
    {{i18n emptyText}}
    - {{/each}} -
    +{{#load-more selector=".user-stream-item" action="loadMore"}} +
    + {{#each model as |post|}} + {{group-post post=post class="user-stream-item item"}} + {{else}} +
    {{i18n emptyText}}
    + {{/each}} +
    + {{conditional-loading-spinner condition=loading}} {{/load-more}} -{{conditional-loading-spinner condition=loading}} diff --git a/app/assets/javascripts/discourse/templates/group-index.hbs b/app/assets/javascripts/discourse/templates/group-index.hbs index ee9f4c8659..1d9caddcd8 100644 --- a/app/assets/javascripts/discourse/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/templates/group-index.hbs @@ -1,20 +1,24 @@
    - {{text-field value=filterInput - placeholderKey=filterPlaceholder - class="group-username-filter no-blur"}} - - {{#if canManageGroup}} - {{#if currentUser.admin}} - {{group-members-dropdown - showAddMembersModal="showAddMembersModal" - showBulkAddModal="showBulkAddModal"}} - {{else}} - {{d-button icon="plus" - action="showAddMembersModal" - label="groups.add_members.title" - class="group-members-add"}} - {{/if}} + {{#if hasMembers}} + {{text-field value=filterInput + placeholderKey=filterPlaceholder + class="group-username-filter no-blur"}} {{/if}} + +
    + {{#if canManageGroup}} + {{d-button icon="plus" + action="showAddMembersModal" + label="groups.add_members.title" + class="group-members-add"}} + {{#if currentUser.admin}} + {{d-button icon="plus" + action="showBulkAddModal" + label="admin.groups.bulk_add.title" + class="group-bulk-add"}} + {{/if}} + {{/if}} +
    {{#if hasMembers}} diff --git a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs index acf57adb03..c497fa9ff9 100644 --- a/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/discovery/topics.hbs @@ -40,12 +40,12 @@ {{#footer-message education=footerEducation message=footerMessage}} {{#if latest}} - {{#if canCreateTopicOnCategory}}{{i18n 'topic.suggest_create_topic'}}{{/if}} + {{#if canCreateTopicOnCategory}}{{i18n 'topic.suggest_create_topic'}}.{{/if}} {{else if top}} - {{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}} + {{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}. {{top-period-buttons period=period action="changePeriod"}} {{else}} - {{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} + {{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}. {{/if}} {{/footer-message}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs b/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs index 36146e7ee4..0b173d7deb 100644 --- a/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs +++ b/app/assets/javascripts/discourse/templates/modal/feature-topic.hbs @@ -36,12 +36,22 @@

    {{i18n "topic.feature_topic.pin_note"}}

    -

    - {{{pinMessage}}} - {{d-icon "clock-o"}} - {{date-picker-future value=model.pinnedInCategoryUntil}} - {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} -

    + {{#if site.isMobileDevice}} +

    + {{{pinMessage}}} +

    +

    + {{date-picker-future value=model.pinnedInCategoryUntil}} + {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} +

    + {{else}} +

    + {{{pinMessage}}} + {{d-icon "clock-o"}} + {{date-picker-future value=model.pinnedInCategoryUntil}} + {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} +

    + {{/if}}

    {{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary"}}

    @@ -62,12 +72,22 @@

    {{i18n "topic.feature_topic.global_pin_note"}}

    -

    - {{i18n "topic.feature_topic.pin_globally"}} - {{d-icon "clock-o"}} - {{date-picker-future value=model.pinnedGloballyUntil}} - {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} -

    + {{#if site.isMobileDevice}} +

    + {{i18n "topic.feature_topic.pin_globally"}} +

    +

    + {{date-picker-future value=model.pinnedGloballyUntil}} + {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} +

    + {{else}} +

    + {{i18n "topic.feature_topic.pin_globally"}} + {{d-icon "clock-o"}} + {{date-picker-future value=model.pinnedGloballyUntil}} + {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} +

    + {{/if}}

    {{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary"}}

    diff --git a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs index 6d6a535f59..d4d258c748 100644 --- a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs @@ -24,6 +24,7 @@ {{desktop-notification-config}}
    {{i18n 'user.desktop_notifications.each_browser_note'}}
    + {{plugin-outlet name="user-preferences-desktop-notifications" args=(hash model=model save=(action "save"))}}
    {{#if siteSettings.enable_personal_messages}} diff --git a/app/assets/javascripts/discourse/templates/tag-groups-show.hbs b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs index 80f4efc3b3..60f2f48bcd 100644 --- a/app/assets/javascripts/discourse/templates/tag-groups-show.hbs +++ b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs @@ -30,15 +30,15 @@
    - {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="public" id="public-permission" selection=model.permissionName}} + {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="public" id="public-permission" selection=model.permissionName onChange="changed"}}
    - {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="visible" id="visible-permission" selection=model.permissionName}} + {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="visible" id="visible-permission" selection=model.permissionName onChange="changed"}}
    - {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="private" id="private-permission" selection=model.permissionName}} + {{radio-button class="tag-permissions-choice" name="tag-permissions-choice" value="private" id="private-permission" selection=model.permissionName onChange="changed"}}
    diff --git a/app/assets/javascripts/discourse/templates/tags/show.hbs b/app/assets/javascripts/discourse/templates/tags/show.hbs index 95865c66f3..1196908497 100644 --- a/app/assets/javascripts/discourse/templates/tags/show.hbs +++ b/app/assets/javascripts/discourse/templates/tags/show.hbs @@ -66,7 +66,7 @@ {{else}}

    - {{footerMessage}}{{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} + {{footerMessage}}{{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}.

    {{/if}} diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 index b54c7caf4b..957fd246a8 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 @@ -90,23 +90,35 @@ export default createWidget('hamburger-menu', { const { siteSettings } = this; const links = []; - links.push({ route: 'discovery.latest', className: 'latest-topics-link', label: 'filters.latest.title' }); + links.push({ + route: 'discovery.latest', + className: 'latest-topics-link', + label: 'filters.latest.title', + title: 'filters.latest.help' + }); if (this.currentUser) { links.push({ route: 'discovery.new', className: 'new-topics-link', labelCount: 'filters.new.title_with_count', label: 'filters.new.title', + title: 'filters.new.help', count: this.lookupCount('new') }); links.push({ route: 'discovery.unread', className: 'unread-topics-link', labelCount: 'filters.unread.title_with_count', label: 'filters.unread.title', + title: 'filters.unread.help', count: this.lookupCount('unread') }); } - links.push({ route: 'discovery.top', className: 'top-topics-link', label: 'filters.top.title' }); + links.push({ + route: 'discovery.top', + className: 'top-topics-link', + label: 'filters.top.title', + title: 'filters.top.help' + }); if (siteSettings.enable_badges) { links.push({ route: 'badges', className: 'badge-link', label: 'badges.title' }); diff --git a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 index 16333eb951..107383d935 100644 --- a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 @@ -20,7 +20,9 @@ export default class PostCooked { } update(prev) { - if (prev.attrs.cooked !== this.attrs.cooked) { + if ((prev.attrs.cooked !== this.attrs.cooked) || + (prev.attrs.highlightTerm !== this.attrs.highlightTerm)) { + return this.init(); } } diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 5bd25047f1..aa2a45c4a7 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -12,6 +12,7 @@ export function initSearchData() { searchData.term = undefined; searchData.typeFilter = null; searchData.invalidTerm = false; + searchData.topicId = null; } initSearchData(); @@ -64,6 +65,13 @@ const SearchHelper = { } searchData.results = content; + + if (searchContext && searchContext.type === 'topic') { + widget.appEvents.trigger('post-stream:refresh', { force: true }); + searchData.topicId = searchContext.id; + } else { + searchData.topicId = null; + } }).finally(() => { searchData.loading = false; widget.scheduleRerender(); @@ -163,11 +171,20 @@ export default createWidget('search-menu', { }, html(attrs) { - if (searchData.contextEnabled !== attrs.contextEnabled) { - searchData.contextEnabled = attrs.contextEnabled; - if (searchData.term) this.triggerSearch(); - } else { - searchData.contextEnabled = attrs.contextEnabled; + searchData.contextEnabled = attrs.contextEnabled; + const searchContext = this.searchContext(); + + const shouldTriggerSearch = ( + (searchData.contextEnabled !== attrs.contextEnabled) || + (searchContext && + searchContext.type === 'topic' && + searchData.topicId !== null && + searchData.topicId !== searchContext.id + ) + ); + + if (shouldTriggerSearch && searchData.term) { + this.triggerSearch(); } return this.attach('menu-panel', { maxWidth: 500, contents: () => this.panelContents() }); diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 index 2e04ab2ab3..8688c3d6e4 100644 --- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 @@ -146,12 +146,11 @@ export default createWidget('topic-admin-menu', { icon: 'lock', label: 'actions.close' }); } - if (this.currentUser.get('staff')) { - buttons.push({ className: 'topic-admin-status-update', + + buttons.push({ className: 'topic-admin-status-update', action: 'showTopicStatusUpdate', icon: 'clock-o', label: 'actions.timed_update' }); - } const isPrivateMessage = topic.get('isPrivateMessage'); diff --git a/app/assets/javascripts/select-kit/components/period-chooser.js.es6 b/app/assets/javascripts/select-kit/components/period-chooser.js.es6 index 31edf3f7d3..847b9a4a4a 100644 --- a/app/assets/javascripts/select-kit/components/period-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/period-chooser.js.es6 @@ -1,5 +1,5 @@ import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; -import computed from "ember-addons/ember-computed-decorators"; +import computed, { on } from "ember-addons/ember-computed-decorators"; export default DropdownSelectBoxComponent.extend({ classNames: ["period-chooser"], @@ -14,6 +14,14 @@ export default DropdownSelectBoxComponent.extend({ return isExpanded ? "caret-up" : "caret-down"; }, + + @on("didReceiveAttrs") + _setFullDay() { + this.get("headerComponentOptions").setProperties({fullDay: this.get("fullDay")}); + this.get("rowComponentOptions").setProperties({fullDay: this.get("fullDay")}); + }, + + actions: { onSelect() { this.sendAction("action", this.get("computedValue")); diff --git a/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-header.hbs b/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-header.hbs index 2f0f825d00..698c054c93 100644 --- a/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-header.hbs +++ b/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-header.hbs @@ -1,5 +1,5 @@

    - {{period-title value showDateRange=true}} + {{period-title value showDateRange=true fullDay=options.fullDay}}

    {{d-icon caretIcon class="caret-icon"}} diff --git a/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-row.hbs b/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-row.hbs index 549bf27335..d37693289a 100644 --- a/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-row.hbs +++ b/app/assets/javascripts/select-kit/templates/components/period-chooser/period-chooser-row.hbs @@ -1,5 +1,5 @@ - {{period-title value showDateRange=true}} + {{period-title value showDateRange=true fullDay=options.fullDay}} diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index f091e07dc2..fba1cfdef2 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -95,6 +95,80 @@ self.addEventListener('fetch', function(event) { // handled by the browser as if there were no service worker involvement. }); + +const idleThresholdTime = 1000 * 10; // 10 seconds +var lastAction = -1; + +function isIdle() { + return lastAction + idleThresholdTime < Date.now(); +} + +function showNotification(title, body, icon, badge, tag, baseUrl, url) { + var notificationOptions = { + body: body, + icon: icon, + badge: badge, + data: { url: url, baseUrl: baseUrl }, + tag: tag + } + + return self.registration.showNotification(title, notificationOptions); +} + +self.addEventListener('push', function(event) { + var payload = event.data.json(); + if(!isIdle() && payload.hide_when_active) { + return false; + } + + event.waitUntil( + self.registration.getNotifications({ tag: payload.tag }).then(function(notifications) { + if (notifications && notifications.length > 0) { + notifications.forEach(function(notification) { + notification.close(); + }); + } + + return showNotification(payload.title, payload.body, payload.icon, payload.badge, payload.tag, payload.base_url, payload.url); + }) + ); +}); + +self.addEventListener('notificationclick', function(event) { + // Android doesn't close the notification when you click on it + // See: http://crbug.com/463146 + event.notification.close(); + var url = event.notification.data.url; + var baseUrl = event.notification.data.baseUrl; + + // This looks to see if the current window is already open and + // focuses if it is + event.waitUntil( + clients.matchAll({ type: "window" }) + .then(function(clientList) { + var reusedClientWindow = clientList.some(function(client) { + if (client.url === baseUrl + url && 'focus' in client) { + client.focus(); + return true; + } + + if ('postMessage' in client && 'focus' in client) { + client.focus(); + client.postMessage({ url: url }); + return true; + } + return false; + }); + + if (!reusedClientWindow && clients.openWindow) return clients.openWindow(baseUrl + url); + }) + ); +}); + +self.addEventListener('message', function(event) { + if('lastAction' in event.data){ + lastAction = event.data.lastAction; + }}); <% DiscoursePluginRegistry.service_workers.each do |js| %> <%=raw "#{File.read(js)}" %> <% end %> diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index d3ee8fb0f8..33da3ec0e0 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -578,6 +578,10 @@ $mobile-breakpoint: 700px; background-color: $highlight-medium; } } + + .warning { + color: $danger; + } } section.details { @@ -854,25 +858,81 @@ section.details { } } -.version-check { +.version-checks { + display: flex; + flex-wrap: wrap; - th { - text-align: left !important; + .section-title { + flex: 1 1 100%; + border-bottom: 1px solid $primary-low; + margin-bottom: .5em; + } +} + +.version-check { + display: flex; + flex: 1 1 50%; + flex-wrap: wrap; + align-items: flex-start; + align-self: flex-start; + justify-content: space-between; + padding: 10px 0 10px 0; + + .upgrade-header { + flex: 1 1 100%; + margin: .25em 0 1em 0; + @media screen and (max-width: 650px) { + margin: 0; + } + tr { + border: none; + } + th { + background: transparent; + text-align: left; + padding: 0; + } + } + + h2 { + flex: 1 1 100%; } .version-number { - font-size: $font-up-3; - font-weight: bold; + font-size: $font-up-2; line-height: $line-height-medium; + box-sizing: border-box; + font-weight: bold; + margin: 0 0 1em 0; + padding-right: 20px; + flex: 1 1 27%; + h3 { + flex: 1 0 auto; + white-space: nowrap; + } + h4 { + font-size: $font-down-2; + margin-bottom: 0; + } } - .face { - width: 20px; + .version-status { + display: flex; + align-items: center; + margin: 0 0 1em 0; + flex: 1 1 24%; + box-sizing: border-box; + padding-right: 20px; + min-width: 250px; + @include small-width { + max-width: unset; + } + .face { + margin: 0 .75em 0 0; + font-size: $font-up-3; + } } - .version-notes .fa { - vertical-align: bottom; - } &.critical .version-notes .normal-note { display: none; @@ -921,13 +981,27 @@ table.api-keys { } .dashboard-stats { + box-sizing: border-box; margin-bottom: 30px; - margin-right: 40px; + flex: 1 1 50%; + box-sizing: border-box; + &.version-check { + margin: 0; + } + &.detected-problems { + border-left: 1px solid $primary-low; + margin: 10px 0 0 0; + padding-left: 20px; + } h4 { font-weight: normal; margin-bottom: 8px; } + @media screen and (max-width: 650px) { + flex: 1 1 100%; + } + table { width: 100%; @@ -993,40 +1067,56 @@ table.api-keys { } &.detected-problems { - background: $primary-low; - margin-bottom: 20px; + display: flex; + margin-bottom: 30px; .look-here { - float: left; - margin: 20px 10px 0 10px; + margin: 10px 20px; .fa { font-size: $font-up-5; - vertical-align: middle; - color: $primary + color: $danger } } + @media screen and (max-width: 650px) { + border-left: none; + border-top: 1px solid $primary-low; + padding: 20px 0 0 0; + .look-here { + margin-left: 0; + } + } + h3 { + display: flex; + } + .problem-messages { - float: left; - width: 80%; - margin-left: 1%; + display: flex; + a { text-decoration: underline; } - .actions { - text-align: right; - } + .btn { background: $primary-low; } ul { margin-left: 0; - padding-left: 20px; + padding-left: 90px; + @media screen and (max-width:650px) { + padding-left: 20px; + } li { margin-bottom: 10px; } } + p.actions { + padding-left: 75px; + @media screen and (max-width:650px) { + padding-left: 0; + } + } } } diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 6a5457eb76..71cb1ec305 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -1,9 +1,12 @@ .dashboard-next { - &.admin-contents { margin: 0; } + .section-top { + margin-bottom: 1em; + } + .section-columns { display: flex; justify-content: space-between; @@ -28,12 +31,12 @@ } } - .section-column:last-child { - margin-left: .5em; + .section-column:last-child, { + margin-left: 1em; } .section-column:first-child { - margin-right: .5em; + margin-right: 1em; } @include small-width { @@ -52,7 +55,7 @@ display: flex; align-items: center; justify-content: space-between; - border-bottom: 1px solid $primary-low-mid; + border-bottom: 1px solid $primary-low; margin-bottom: .5em; padding-bottom: .5em; } @@ -62,180 +65,29 @@ } } - .dashboard-table { - margin-bottom: 1em; - - @include small-width { - table { - tbody tr td { - font-size: $font-down-2; - } - } - } - - &.is-loading { - height: 150px; - } - - .table-title { - align-items: center; - display: flex; - justify-content: space-between; - - h3 { - margin: .5em 0; - } - } - - table { - border: 1px solid $primary-low-mid; - table-layout: fixed; - - thead { - tr { - background: $primary-low; - th { - border: 1px solid $primary-low-mid; - text-align: center; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } - } - - tbody { - tr { - td:first-child { - text-overflow: ellipsis; - overflow: hidden; - white-space: normal; - } - - td { - border: 1px solid $primary-low-mid; - text-align: center; - } - - td.value { - i { - display: none; - } - - &.high-trending-up, &.trending-up { - i.up { - color: $success; - display: inline; - } - } - &.high-trending-down, &.trending-down { - i.down { - color: $danger; - display: inline; - } - } - &.no-change { - i.down { - display: inline; - visibility: hidden; - } - } - } - } - } - } - } - .charts { display: flex; justify-content: space-between; flex-wrap: wrap; - .dashboard-mini-chart { - max-width: calc(100% * (1/3)); + .chart { + max-width: calc(100% * 1/3.2); width: 100%; - margin-bottom: 1em; flex-grow: 1; - - @include small-width { - max-width: 100%; - } - - &.is-loading { - height: 200px; - } - - .loading-container.visible { - display: flex; - align-items: center; - height: 100%; - width: 100%; - } - - .d-icon-question-circle { - cursor: pointer; - } - - .chart-title { - align-items: center; - display: flex; - justify-content: space-between; - - h3 { - margin: 1em 0; - } - } - - &.high-trending-up, &.trending-up { - .chart-trend, .data-point { - color: rgb(17, 141, 0); - } - } - - &.high-trending-down, &.trending-down { - .chart-trend, .data-point { - color: $danger; - } - } - - &.one-data-point { - .chart-container { - min-height: 150px; - justify-content: center; - align-items: center; - display: flex; - } - - .data-point { - width: 100%; - font-size: 6em; - font-weight: bold; - border-radius: 3px; - background: rgba(200,220,240,0.3); - text-align: center; - padding: .5em 0; - } - } + flex-basis: 100%; + display: flex; + margin-bottom: 1em; } @include small-width { - .dashboard-mini-chart { - width: 100%; + .chart { + max-width: 100%; } } - .chart-container { + .chart-canvas-container { position: relative; - padding: 0 1em; - } - - .chart-trend { - font-size: $font-up-3; - display: flex; - justify-content: space-between; - align-items: center; - font-weight: bold; - margin-right: 1em; + margin-left: -5px; } .chart-canvas { @@ -245,13 +97,283 @@ } .misc { + display: flex; + border: 1px solid $primary-low; + + .durability, .last-dashboard-update { + flex: 1 1 50%; + box-sizing: border-box; + margin: 20px 0; + padding: 0 20px; + } + .durability { display: flex; + flex-wrap: wrap; justify-content: space-between; + .backups, .uploads { + flex: 1 1 100%; + } + + .uploads p:last-of-type { + margin-bottom: 0; + } .durability-title { text-transform: capitalize; } } + + @media screen and (max-width: 400px) { + flex-wrap: wrap; + .durability, .last-dashboard-update { + flex: 1 1 100%; + text-align: left; + } + .last-dashboard-update { + display: block; + margin: 0 20px 20px 20px; + padding: 20px 0 0 0; + border-top: 1px solid $primary-low; + border-left: none; + } + } + .last-dashboard-update { + border-left: 1px solid $primary-low; + text-align: center; + display: flex; + justify-content: center; + div { + align-self: center; + h4 { + margin-bottom: 0; + } + } + } + } + + + + .community-health { + .period-chooser .period-chooser-header { + .selected-name, .d-icon { + font-size: $font-up-1; + } + + .d-icon { + margin: 0; + } + } + } +} + + +.dashboard-mini-chart { + .status { + display: flex; + justify-content: space-between; + margin-bottom: .5em; + padding: 0 .45em 0 0; + + .title { + font-size: $font-up-1; + font-weight: 700; + margin: 0; + + a { color: $primary; } + + .info { + cursor: pointer; + margin-left: .25em; + color: $primary-low-mid; + } + } + + .trend { + align-items: center; + + &.trending-down, &.high-trending-down { + color: $danger; + } + + &.trending-up, &.high-trending-up { + color: $success; + } + + .trend-value { + font-size: $font-up-1; + } + + .trend-icon { + font-size: $font-up-1; + font-weight: 700; + } + } + } + + .conditional-loading-section { + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + width: 100%; + } + + @include small-width { + max-width: 100%; + } + + &.is-loading { + height: 200px; + } + + .loading-container.visible { + display: flex; + align-items: center; + height: 100%; + width: 100%; + } + + .d-icon-question-circle { + cursor: pointer; + } + + .chart-title { + align-items: center; + display: flex; + justify-content: space-between; + + h3 { + margin: 1em 0; + a, a:visited { + color: $primary; + } + } + } + + &.high-trending-up, &.trending-up { + .chart-trend, .data-point { + color: $success; + } + } + + &.high-trending-down, &.trending-down { + .chart-trend, .data-point { + color: $danger; + } + } +} + +.top-referred-topics, .trending-search { + th:first-of-type { + text-align: left; + } +} + +.top-referred-topics { + .dashboard-table table { + table-layout: auto; + } +} + +.dashboard-table { + margin-bottom: 1em; + + &.is-disabled { + background: $primary-low; + padding: 1em; + } + + @media screen and (max-width: 650px) { + table { + tbody tr td { + font-size: $font-down-1; + } + } + } + + &.is-loading { + height: 150px; + } + + .table-title { + align-items: center; + display: flex; + justify-content: space-between; + + h3 { + margin: 1em 0 0 0; + } + } + + table { + table-layout: fixed; + border: 1px solid $primary-low; + + thead { + border: 1px solid $primary-low; + tr { + th { + text-align: center; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + } + + tbody { + border-top: none; + tr { + td:first-child { + text-overflow: ellipsis; + overflow: hidden; + white-space: normal; + } + + td { + text-align: center; + padding: 8px; + } + td.left { + text-align: left; + } + + td.title { + text-align: left; + } + + td.value { + text-align: right; + transform: translateX(-40%); + i { + display: none; + margin-right: -12px; // align on caret + @media screen and (max-width: 650px) { + margin-right: -9px; + } + } + + &.high-trending-up, &.trending-up { + i.up { + color: $success; + display: inline; + } + } + &.high-trending-down, &.trending-down { + i.down { + color: $danger; + display: inline; + } + } + &.no-change { + i.down { + display: inline; + visibility: hidden; + } + } + } + } + } } } diff --git a/app/assets/stylesheets/common/base/category-list.scss b/app/assets/stylesheets/common/base/category-list.scss index ff00a12116..ebd131dadb 100644 --- a/app/assets/stylesheets/common/base/category-list.scss +++ b/app/assets/stylesheets/common/base/category-list.scss @@ -26,6 +26,7 @@ display: flex; flex-direction: row; align-content: flex-start; + cursor: pointer; box-sizing: border-box; diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index 9852f3379a..fa088560cd 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -14,18 +14,19 @@ } .group-members-actions { - position: absolute; - top: -49px; - right: 0px; + display: flex; + flex-wrap: wrap; + width: 100%; + + input + .group-members-manage { + margin-left: auto; + } .group-username-filter { - margin: 0px; + margin: 0 0 5px 0; vertical-align: middle; } - .group-members-dropdown, .group-members-add { - vertical-align: middle; - } } .group-info { diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index d1d4144c36..bf3ee57cfa 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -114,6 +114,10 @@ height: 100%; z-index: z("modal","content"); overflow: auto; + + .modal-body { + max-height: none !important; + } } .modal-form { diff --git a/app/assets/stylesheets/common/base/tooltip.scss b/app/assets/stylesheets/common/base/tooltip.scss new file mode 100644 index 0000000000..6dd84a8de2 --- /dev/null +++ b/app/assets/stylesheets/common/base/tooltip.scss @@ -0,0 +1,60 @@ +$discourse-tooltip-background: $secondary; +$discourse-tooltip-border: $primary-medium; + +#discourse-tooltip { + background-color: $discourse-tooltip-background; + position: absolute; + z-index: 1000; + border: 1px solid $discourse-tooltip-border; + max-width: 400px; + margin-top: 25px; + overflow-wrap: break-word; + display: none; + font-size: $font-0; + font-weight: 500; + + &.retina { + border: 0.5px solid $discourse-tooltip-border; + } + + .tooltip-pointer { + position: relative; + background: $discourse-tooltip-background; + } + + .tooltip-pointer:before, .tooltip-pointer:after { + position: absolute; + pointer-events: none; + border: solid transparent; + bottom: 100%; + content: ""; + height: 0; + width: 0; + } + + .tooltip-pointer:after + { + border-bottom-color: $discourse-tooltip-background; + border-width: 8px; + left: 50%; + margin-left: -8px; + margin-bottom: -0.5px; + } + + .tooltip-pointer:before { + border-bottom-color: $discourse-tooltip-border; + border-width: 9px; + left: 50%; + margin-left: -9px; + margin-bottom: -0.5px; + } + + .tooltip-content { + padding: 1em; + max-width: 250px; + font-size: $font-down-1; + color: $primary; + box-shadow: shadow("dropdown"); + line-height: 1.4em; + } +} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index adee44fcc0..80668a280b 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -53,34 +53,34 @@ } h1 { - font-size: $font-up-4; - } - - h2 { font-size: $font-up-3; } - h3 { + h2 { font-size: $font-up-2; } - h4 { + h3 { font-size: $font-up-1; } - h5 { + h4 { font-size: $font-0; } - h6 { + h5 { font-size: $font-down-1; } + h6 { + font-size: $font-down-2; + } + a { word-wrap: break-word; } ins { background-color: dark-light-choose($success-low, scale-color($success, $lightness: -60%)); } del { background-color: dark-light-choose($danger-low, scale-color($danger, $lightness: -60%)); } // Prevents users from breaking posts with tag nesting - big { font-size: 2rem; } + big { font-size: 1.5rem; } small { font-size: 0.75rem; } small small { font-size: .75em; } big big { font-size: 1em; } diff --git a/app/assets/stylesheets/common/components/user-stream-item.scss b/app/assets/stylesheets/common/components/user-stream-item.scss index 5c34d2c5bc..ea79b2e3bb 100644 --- a/app/assets/stylesheets/common/components/user-stream-item.scss +++ b/app/assets/stylesheets/common/components/user-stream-item.scss @@ -116,6 +116,10 @@ color: $primary-medium; } } + + .group-member-info { + display: flex; + } } .user-stream .child-actions, // DEPRECATED: '.user-stream .child-actions' selector diff --git a/app/assets/stylesheets/common/select-kit/combo-box.scss b/app/assets/stylesheets/common/select-kit/combo-box.scss index fa1c68c2d3..39365ffa9c 100644 --- a/app/assets/stylesheets/common/select-kit/combo-box.scss +++ b/app/assets/stylesheets/common/select-kit/combo-box.scss @@ -5,7 +5,7 @@ width: 100%; min-width: 150px; border-radius: 0; - box-shadow: shadow("dropdown"); + box-shadow: shadow("dropdown"); } .select-kit-row { @@ -23,12 +23,18 @@ border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); - .filter-input { - margin-right: 5px; - width: auto; + .filter-input, + .filter-input:focus, + .filter-input:active { + width: auto; + max-width: 90%; // Firefox quirk + } + + .filter-icon { + padding-left: 5px; } } - + .select-kit-header { background: $secondary; border: 1px solid $primary-medium; @@ -36,6 +42,7 @@ font-weight: 500; font-size: $font-0; line-height: $line-height-large; + min-height: 2em; // when no content is available &.is-focused { border: 1px solid $tertiary; @@ -100,14 +107,16 @@ } } - &.tag-drop, &.group-dropdown { + &.tag-drop, + &.group-dropdown { min-width: auto; .select-kit-row { font-weight: bold; } } - &.is-expanded .select-kit-wrapper, .select-kit-wrapper { + &.is-expanded .select-kit-wrapper, + .select-kit-wrapper { display: none; } } diff --git a/app/assets/stylesheets/desktop/user-card.scss b/app/assets/stylesheets/desktop/user-card.scss index df18f58a82..0f637ee1f0 100644 --- a/app/assets/stylesheets/desktop/user-card.scss +++ b/app/assets/stylesheets/desktop/user-card.scss @@ -48,6 +48,11 @@ $user_card_background: $secondary; } } + .bio { + max-height: 150px; + overflow: auto; + } + &.no-bg { min-height: 50px; @@ -83,7 +88,8 @@ $user_card_background: $secondary; h2 { font-size: $font-up-1; - line-height: $line-height-medium; + line-height: $line-height-large; + margin: 0; font-weight: normal; display: block; max-width: 250px; @@ -121,8 +127,8 @@ $user_card_background: $secondary; .metadata { width: 100%; clear: both; - padding-top: 5px; - padding-bottom: 5px; + padding-top: 10px; + padding-bottom: 10px; h3 { display: inline; margin-right: 5px; @@ -141,7 +147,7 @@ $user_card_background: $secondary; } .bio { - padding: 10px 0 0 0; + margin-top: 80px; clear: left; a { @@ -190,17 +196,16 @@ $user_card_background: $secondary; } } - .user-card-avatar { + .user-card-avatar, .group-card-avatar { float: left; margin-right: 10px; margin-top: -53px; } .group-card-avatar { - float: left; - margin-right: 10px; - margin-top: -5px; - $size: 50px; + $size: 120px; + width: $size; + height: $size; .avatar-flair { width: $size; height: $size; @@ -212,12 +217,16 @@ $user_card_background: $secondary; color: $primary; i { margin: auto; - font-size: $size !important; + font-size: $size / 1.5 !important; + } + &.rounded { + border-radius: 50%; } } } .members { + padding: 0 0 10px 0; a { color: lighten($primary, 40%); &:hover { diff --git a/app/assets/stylesheets/mobile/components/user-stream-item.scss b/app/assets/stylesheets/mobile/components/user-stream-item.scss index 4687539d6a..3727bbd76b 100644 --- a/app/assets/stylesheets/mobile/components/user-stream-item.scss +++ b/app/assets/stylesheets/mobile/components/user-stream-item.scss @@ -22,7 +22,6 @@ } .group-member-info { - display: flex; .name { vertical-align: inherit; } diff --git a/app/assets/stylesheets/mobile/push-notifications-mobile.scss b/app/assets/stylesheets/mobile/push-notifications-mobile.scss new file mode 100644 index 0000000000..3d538d62b9 --- /dev/null +++ b/app/assets/stylesheets/mobile/push-notifications-mobile.scss @@ -0,0 +1,3 @@ +.push-notification-prompt .consent_banner { + margin-bottom: 30px; +} diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss index 0af3336172..e9ba6a1679 100644 --- a/app/assets/stylesheets/mobile/topic-list.scss +++ b/app/assets/stylesheets/mobile/topic-list.scss @@ -6,53 +6,121 @@ // -------------------------------------------------- .list-controls { + margin: 10px -3px 5px -3px; .category-breadcrumb.hidden { display: none; } - margin: 5px 0; - .nav { - float: left; - margin-right: 15px; - li { - margin-top: 5px; + + .container { + display: flex; + flex-wrap: wrap; + align-items: center; + #create-topic { + box-sizing: border-box; + display: flex; + align-self: stretch; + margin: 0 3px 10px 3px; + order: 10; // always last for consistent placement } } - .btn { - float: right; - margin-left: 8px; - margin-top: 5px; + + .dropdown-select-box-header { + display: flex; + height: 100%; } - .nav-pills { - position: relative; - } - .nav-pills .drop { - border: 1px solid $primary-low; - position: absolute; - z-index: z("dropdown"); - background-color: $secondary; - padding: 0 10px 10px 10px; - width: 150px; - top: 100%; - margin: 0; - li { - list-style-type: none; - margin-left: 0; - margin-top: 5px; - padding-top: 10px; - a { - width: 100%; - display: inline-block; + + .navigation-container { + display: flex; + flex-wrap: wrap; + width: 100%; + margin: 5px 0; + + button { + margin: 0 3px; + + &.select-kit-header { + display: flex; + height: 100%; + flex: 1 1 auto; } } - } - .nav-pills > li { - background: $primary-low; - font-size: $font-down-1; - .d-icon-caret-down { - margin-left: 8px; + + .select-kit { + display: flex; + align-self: stretch; + margin-bottom: 10px; + } + + .btn:not(.select-kit-header) { + margin-bottom: 10px; + } + + .categories-admin-dropdown, .tag-notifications-button { + order: 2; // after main nav + } + + .category-navigation { + display: flex; + flex-wrap: wrap; + width: 100%; + .edit-category { + i { + margin: 0; + } + @media screen and (max-width: 374px) { // Hide edit label on very tiny screens + .d-button-label { + display: none; + } + } } } + + .nav-pills { + display: flex; + flex: 1 1 auto; + margin: 0 3px 5px 3px; + position: relative; + .navigation-toggle { + flex: 0 1 auto; + margin-bottom: 5px; + } + >li { + margin-right: 0; + background: $primary-low; + font-size: $font-down-1; + } + >li>a { + line-height: $line-height-large; + display: flex; + align-items: center; + .d-icon { + margin-left: 5px; + } + } + .drop { + border: 1px solid $primary-low; + position: absolute; + z-index: z("dropdown"); + background-color: $secondary; + padding: 0 10px 10px 10px; + width: 150px; + top: 100%; + margin: 0; + left: 0; // iOS6 alignment + li { + list-style-type: none; + margin-left: 0; + margin-top: 5px; + padding-top: 10px; + a { + width: 100%; + display: inline-block; + } + } + } + } + } .list-container .full-width { @@ -382,7 +450,20 @@ tr.category-topic-link { } ol.category-breadcrumb { - margin: 5px 10px 0 0; + margin: 0 0 5px 0; + display: flex; + flex-wrap: wrap; + flex: 1 1 100%; + li.select-kit { + flex: 1 1 33%; + margin: 0 3px 5px 3px; + .select-kit-header .selected-name { + max-width: 80vw; + .badge-wrapper { + max-width: 100%; + } + } + } } .top-lists { diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index 561e682ffb..5ab0a5fc5d 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -272,6 +272,7 @@ margin-top: 10px; padding: 5px 0; input { margin-right: 5px; + flex: 0 0 auto; } } diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 51be59fcde..f14f66d562 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -3,7 +3,6 @@ class Admin::DashboardController < Admin::AdminController def index dashboard_data = AdminDashboardData.fetch_cached_stats || Jobs::DashboardStats.new.execute({}) dashboard_data.merge!(version_check: DiscourseUpdates.check_version.as_json) if SiteSetting.version_checks? - dashboard_data[:disk_space] = DiskSpace.cached_stats render json: dashboard_data end diff --git a/app/controllers/admin/dashboard_next_controller.rb b/app/controllers/admin/dashboard_next_controller.rb index 7e6260cbf8..47eef7b5f4 100644 --- a/app/controllers/admin/dashboard_next_controller.rb +++ b/app/controllers/admin/dashboard_next_controller.rb @@ -2,7 +2,8 @@ require 'disk_space' class Admin::DashboardNextController < Admin::AdminController def index - dashboard_data = AdminDashboardNextData.fetch_stats + dashboard_data = AdminDashboardNextData.fetch_cached_stats + dashboard_data.merge!(version_check: DiscourseUpdates.check_version.as_json) if SiteSetting.version_checks? dashboard_data[:disk_space] = DiskSpace.cached_stats render json: dashboard_data end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index e56fe53c11..c8a328000d 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -105,7 +105,7 @@ class Admin::GroupsController < Admin::AdminController end def remove_owner - group = Group.find_by(params.require(:id)) + group = Group.find_by(id: params.require(:id)) raise Discourse::NotFound unless group return can_not_modify_automatic if group.automatic diff --git a/app/controllers/admin/plugins_controller.rb b/app/controllers/admin/plugins_controller.rb index 516c1e0560..9067fa9e8c 100644 --- a/app/controllers/admin/plugins_controller.rb +++ b/app/controllers/admin/plugins_controller.rb @@ -1,7 +1,7 @@ class Admin::PluginsController < Admin::AdminController def index - render_serialized(Discourse.plugins, AdminPluginSerializer, root: 'plugins') + render_serialized(Discourse.visible_plugins, AdminPluginSerializer, root: 'plugins') end end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 17e52f6159..d67ee85432 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -22,16 +22,46 @@ class Admin::ReportsController < Admin::AdminController group_id = nil end - report = Report.find(report_type, - start_date: start_date, - end_date: end_date, - category_id: category_id, - group_id: group_id, - async: params[:async]) + facets = nil + if Array === params[:facets] + facets = params[:facets].map { |s| s.to_s.to_sym } + end - raise Discourse::NotFound if report.blank? + limit = nil + if params.has_key?(:limit) && params[:limit].to_i > 0 + limit = params[:limit].to_i + end + + args = { + start_date: start_date, + end_date: end_date, + category_id: category_id, + group_id: group_id, + facets: facets, + limit: limit + } + + report = nil + if (params[:cache]) + report = Report.find_cached(report_type, args) + end + + if report + return render_json_dump(report: report) + end + + hijack do + report = Report.find(report_type, args) + + raise Discourse::NotFound if report.blank? + + if (params[:cache]) + Report.cache(report, 35.minutes) + end + + render_json_dump(report: report) + end - render_json_dump(report: report) end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d0053c5aaa..b9a24b38fd 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -276,7 +276,7 @@ class Admin::UsersController < Admin::AdminController def deactivate guardian.ensure_can_deactivate!(@user) @user.deactivate - StaffActionLogger.new(current_user).log_user_deactivate(@user, I18n.t('user.deactivated_by_staff')) + StaffActionLogger.new(current_user).log_user_deactivate(@user, I18n.t('user.deactivated_by_staff'), params.slice(:context)) refresh_browser @user render body: nil end @@ -379,7 +379,10 @@ class Admin::UsersController < Admin::AdminController } end rescue UserDestroyer::PostsExistError - raise Discourse::InvalidAccess.new("User #{user.username} has #{user.post_count} posts, so can't be deleted.") + render json: { + deleted: false, + message: "User #{user.username} has #{user.post_count} posts, so they can't be deleted." + } end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c01ad86f1e..fa8e02832a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -58,6 +58,16 @@ class ApplicationController < ActionController::Base layout :set_layout + if Rails.env == "development" + after_action :remember_theme_key + + def remember_theme_key + if @theme_key + Stylesheet::Watcher.theme_key = @theme_key if defined? Stylesheet::Watcher + end + end + end + def has_escaped_fragment? SiteSetting.enable_escaped_fragments? && params.key?("_escaped_fragment_") end diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index 17f20ea338..1a66c74003 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -10,6 +10,14 @@ class DirectoryItemsController < ApplicationController result = DirectoryItem.where(period_type: period_type).includes(:user) + if params[:group] + result = result.includes(user: :groups).where(users: { groups: { name: params[:group] } }) + end + + if params[:exclude_usernames] + result = result.references(:user).where.not(users: { username: params[:exclude_usernames].split(",") }) + end + order = params[:order] || DirectoryItem.headings.first if DirectoryItem.headings.include?(order.to_sym) dir = params[:asc] ? 'ASC' : 'DESC' diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 8773a34970..10933affcf 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -19,7 +19,7 @@ class InvitesController < ApplicationController invite = Invite.find_by(invite_key: params[:id]) - if invite.present? + if invite.present? && !invite.redeemed? store_preloaded("invite_info", MultiJson.dump( invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false), email: invite.email, @@ -28,7 +28,7 @@ class InvitesController < ApplicationController render layout: 'application' else - flash.now[:error] = I18n.t('invite.not_found') + flash.now[:error] = I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url) render layout: 'no_ember' end end diff --git a/app/controllers/push_notification_controller.rb b/app/controllers/push_notification_controller.rb new file mode 100644 index 0000000000..8f7d340b06 --- /dev/null +++ b/app/controllers/push_notification_controller.rb @@ -0,0 +1,21 @@ +class PushNotificationController < ApplicationController + layout false + before_action :ensure_logged_in + skip_before_action :preload_json + + def subscribe + PushNotificationPusher.subscribe(current_user, push_params, params[:send_confirmation]) + render json: success_json + end + + def unsubscribe + PushNotificationPusher.unsubscribe(current_user, push_params) + render json: success_json + end + + private + + def push_params + params.require(:subscription).permit(:endpoint, keys: [:p256dh, :auth]) + end +end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 2e8a75ab07..5a54591a0b 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,5 +1,6 @@ require_dependency 'rate_limiter' require_dependency 'single_sign_on' +require_dependency 'url_helper' class SessionController < ApplicationController class LocalLoginNotAllowed < StandardError; end @@ -43,8 +44,15 @@ class SessionController < ApplicationController def sso_provider(payload = nil) payload ||= request.query_string + if SiteSetting.enable_sso_provider sso = SingleSignOn.parse(payload, SiteSetting.sso_secret) + + if sso.return_sso_url.blank? + render plain: "return_sso_url is blank, it must be provided", status: 400 + return + end + if current_user sso.name = current_user.name sso.username = current_user.username @@ -54,9 +62,17 @@ class SessionController < ApplicationController sso.moderator = current_user.moderator? sso.groups = current_user.groups.pluck(:name).join(",") - if sso.return_sso_url.blank? - render plain: "return_sso_url is blank, it must be provided", status: 400 - return + if current_user.uploaded_avatar.present? + avatar_url = "#{Discourse.store.absolute_base_url}/#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}" + sso.avatar_url = UrlHelper.absolute Discourse.store.cdn_url(avatar_url) + end + + if current_user.user_profile.profile_background.present? + sso.profile_background_url = UrlHelper.absolute upload_cdn_path(current_user.user_profile.profile_background) + end + + if current_user.user_profile.card_background.present? + sso.card_background_url = UrlHelper.absolute upload_cdn_path(current_user.user_profile.card_background) end if request.xhr? @@ -65,7 +81,7 @@ class SessionController < ApplicationController redirect_to sso.to_url(sso.return_sso_url) end else - session[:sso_payload] = request.query_string + cookies[:sso_payload] = request.query_string redirect_to path('/login') end else @@ -388,7 +404,7 @@ class SessionController < ApplicationController session.delete(ACTIVATE_USER_KEY) log_on_user(user) - if payload = session.delete(:sso_payload) + if payload = cookies.delete(:sso_payload) sso_provider(payload) else render_serialized(user, UserSerializer) diff --git a/app/controllers/tag_groups_controller.rb b/app/controllers/tag_groups_controller.rb index 069f73830a..65748f8378 100644 --- a/app/controllers/tag_groups_controller.rb +++ b/app/controllers/tag_groups_controller.rb @@ -33,7 +33,7 @@ class TagGroupsController < ApplicationController if @tag_group.save render_serialized(@tag_group, TagGroupSerializer) else - return render_json_error(@tag_group) + render_json_error(@tag_group) end end @@ -52,8 +52,7 @@ class TagGroupsController < ApplicationController def search matches = if params[:q].present? - term = params[:q].strip.downcase - TagGroup.where('lower(name) like ?', "%#{term}%") + TagGroup.where('lower(name) ILIKE ?', "%#{params[:q].strip}%") else TagGroup.all end @@ -82,7 +81,7 @@ class TagGroupsController < ApplicationController :one_per_topic, tag_names: [], parent_tag_name: [], - permissions: [*permissions&.keys] + permissions: permissions&.keys, ) result[:tag_names] ||= [] result[:parent_tag_name] ||= [] diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 4069ab6093..67c4ee54d3 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -32,25 +32,26 @@ class TagsController < ::ApplicationController end format.json do + show_all_tags = guardian.can_admin_tags? && guardian.is_admin? + if SiteSetting.tags_listed_by_group - grouped_tag_counts = TagGroup.allowed(guardian).order('name ASC').includes(:tags).map do |tag_group| + ungrouped_tags = Tag.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships)") + ungrouped_tags = ungrouped_tags.where("tags.topic_count > 0") unless show_all_tags + + grouped_tag_counts = TagGroup.visible(guardian).order('name ASC').includes(:tags).map do |tag_group| { id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json(tag_group.tags) } end - ungrouped_tags = Tag.where("tags.id NOT IN (select tag_id from tag_group_memberships) AND tags.topic_count > 0") - render json: { - tags: self.class.tag_counts_json(ungrouped_tags), # tags that don't belong to a group + tags: self.class.tag_counts_json(ungrouped_tags), extras: { tag_groups: grouped_tag_counts } } else - unrestricted_tags = DiscourseTagging.filter_visible( - Tag.where("tags.id NOT IN (select tag_id from category_tags) AND tags.topic_count > 0"), - guardian - ) + tags = show_all_tags ? Tag.all : Tag.where("tags.topic_count > 0") + unrestricted_tags = DiscourseTagging.filter_visible(tags, guardian) - categories = Category.where("id in (select category_id from category_tags)") - .where("id in (?)", guardian.allowed_category_ids) + categories = Category.where("id IN (SELECT category_id FROM category_tags)") + .where("id IN (?)", guardian.allowed_category_ids) .includes(:tags) category_tag_counts = categories.map do |c| @@ -212,7 +213,7 @@ class TagsController < ::ApplicationController end def self.tag_counts_json(tags) - tags.map { |t| { id: t.name, text: t.name, count: t.topic_count } } + tags.map { |t| { id: t.name, text: t.name, count: t.topic_count, pm_count: t.pm_topic_count } } end def set_category_from_params diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index c8edddf65b..3341ff44f4 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -125,7 +125,8 @@ class Users::OmniauthCallbacksController < ApplicationController # automatically activate/unstage any account if a provider marked the email valid if @auth_result.email_valid && @auth_result.email == user.email - user.update!(staged: false) + user.unstage + user.save # ensure there is an active email token unless EmailToken.where(email: user.email, confirmed: true).exists? || diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index dfae9f3bdd..5b47b9c6f6 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -663,14 +663,17 @@ class UsersController < ApplicationController end def account_created - return redirect_to("/") if current_user.present? + if current_user.present? + if SiteSetting.enable_sso_provider && payload = cookies.delete(:sso_payload) + return redirect_to(session_sso_provider_url + "?" + payload) + else + return redirect_to("/") + end + end @custom_body_class = "static-account-created" @message = session['user_created_message'] || I18n.t('activation.missing_session') - @account_created = { - message: @message, - show_controls: false - } + @account_created = { message: @message, show_controls: false } if session_user_id = session[SessionController::ACTIVATE_USER_KEY] if user = User.where(id: session_user_id.to_i).first @@ -696,8 +699,8 @@ class UsersController < ApplicationController def perform_account_activation raise Discourse::InvalidAccess.new if honeypot_or_challenge_fails?(params) - if @user = EmailToken.confirm(params[:token]) + if @user = EmailToken.confirm(params[:token]) # Log in the user unless they need to be approved if Guardian.new(@user).can_access_forum? @user.enqueue_welcome_message('welcome_user') if @user.send_welcome_message @@ -708,14 +711,16 @@ class UsersController < ApplicationController elsif destination_url = cookies[:destination_url] cookies[:destination_url] = nil return redirect_to(destination_url) + elsif SiteSetting.enable_sso_provider && payload = cookies.delete(:sso_payload) + return redirect_to(session_sso_provider_url + "?" + payload) end else @needs_approval = true end - else flash.now[:error] = I18n.t('activation.already_done') end + render layout: 'no_ember' end @@ -736,7 +741,6 @@ class UsersController < ApplicationController User.transaction do primary_email = @user.primary_email - primary_email.email = params[:email] primary_email.skip_validate_email = false diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cfc0006677..696a2a870a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -75,6 +75,13 @@ module ApplicationHelper path.gsub!("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/") end + if Rails.env == "development" + if !path.include?("?") + # cache breaker for mobile iOS + path = path + "?#{Time.now.to_f}" + end + end + " ".html_safe end diff --git a/app/helpers/embed_helper.rb b/app/helpers/embed_helper.rb index 53eedd6259..3115f59e1f 100644 --- a/app/helpers/embed_helper.rb +++ b/app/helpers/embed_helper.rb @@ -14,27 +14,7 @@ module EmbedHelper end end - def get_html(cooked) - fragment = Nokogiri::HTML.fragment(cooked) - - # convert lazyYT div to link - fragment.css('div.lazyYT').each do |yt_div| - youtube_id = yt_div["data-youtube-id"] - youtube_link = "https://www.youtube.com/watch?v=#{youtube_id}" - yt_div.replace "

    #{youtube_link}

    " - end - - # convert Vimeo iframe to link - fragment.css('iframe').each do |iframe| - if iframe['src'] =~ /player.vimeo.com/ - vimeo_id = iframe['src'].split('/').last - iframe.replace "

    https://vimeo.com/#{vimeo_id}

    " - end - end - - # Strip lightbox metadata - fragment.css('.lightbox-wrapper .meta').remove - - raw fragment + def get_html(post) + raw PrettyText.format_for_email(post.cooked, post) end end diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb index dd57d45794..b284f69c61 100644 --- a/app/helpers/user_notifications_helper.rb +++ b/app/helpers/user_notifications_helper.rb @@ -37,13 +37,15 @@ module UserNotificationsHelper result = "" length = 0 - doc.css('body > p, aside.onebox, body > ul').each do |node| + + doc.css('body > p, aside.onebox, body > ul, body > blockquote').each do |node| if node.text.present? result << node.to_s length += node.inner_text.length return result if length >= SiteSetting.digest_min_excerpt_length end end + return result unless result.blank? # If there is no first paragaph, return the first div (onebox) diff --git a/app/jobs/regular/automatic_group_membership.rb b/app/jobs/regular/automatic_group_membership.rb index 4e688d8400..2b53ffb49c 100644 --- a/app/jobs/regular/automatic_group_membership.rb +++ b/app/jobs/regular/automatic_group_membership.rb @@ -4,10 +4,10 @@ module Jobs def execute(args) group_id = args[:group_id] - raise Discourse::InvalidParameters.new(:group_id) if group_id.blank? - group = Group.find(group_id) + group = Group.find_by(id: group_id) + raise Discourse::InvalidParameters.new(:group_id) if group.nil? return unless group.automatic_membership_retroactive diff --git a/app/jobs/regular/download_profile_background_from_url.rb b/app/jobs/regular/download_profile_background_from_url.rb new file mode 100644 index 0000000000..43414db9d1 --- /dev/null +++ b/app/jobs/regular/download_profile_background_from_url.rb @@ -0,0 +1,28 @@ +module Jobs + + class DownloadProfileBackgroundFromUrl < Jobs::Base + sidekiq_options retry: false + + def execute(args) + url = args[:url] + user_id = args[:user_id] + + raise Discourse::InvalidParameters.new(:url) if url.blank? + raise Discourse::InvalidParameters.new(:user_id) if user_id.blank? + + return unless user = User.find_by(id: user_id) + + begin + UserProfile.import_url_for_user( + url, + user, + is_card_background: args[:is_card_background], + ) + rescue Discourse::InvalidParameters => e + raise e unless e.message == 'url' + end + end + + end + +end diff --git a/app/jobs/regular/retrieve_report.rb b/app/jobs/regular/retrieve_report.rb deleted file mode 100644 index 15a37377cb..0000000000 --- a/app/jobs/regular/retrieve_report.rb +++ /dev/null @@ -1,24 +0,0 @@ -require_dependency 'report' - -module Jobs - class RetrieveReport < Jobs::Base - sidekiq_options retry: false - - def execute(args) - raise Discourse::InvalidParameters.new(:report_type) if !args["report_type"] - - type = args.delete("report_type") - report = Report.new(type) - report.start_date = args["start_date"].to_date if args["start_date"] - report.end_date = args["end_date"].to_date if args["end_date"] - report.category_id = args["category_id"] if args["category_id"] - report.group_id = args["group_id"] if args["group_id"] - - Report.send("report_#{type}", report) - - Discourse.cache.write(Report.cache_key(report), report.as_json, force: true, expires_in: 30.minutes) - - MessageBus.publish("/admin/reports/#{type}", report.as_json, user_ids: User.staff.pluck(:id)) - end - end -end diff --git a/app/jobs/regular/send_push_notification.rb b/app/jobs/regular/send_push_notification.rb new file mode 100644 index 0000000000..c27f1bbf27 --- /dev/null +++ b/app/jobs/regular/send_push_notification.rb @@ -0,0 +1,8 @@ +module Jobs + class SendPushNotification < Jobs::Base + def execute(args) + user = User.find(args[:user_id]) + PushNotificationPusher.push(user, args[:payload]) + end + end +end diff --git a/app/jobs/regular/update_username.rb b/app/jobs/regular/update_username.rb index ebc5f1aa0b..e8a45d9366 100644 --- a/app/jobs/regular/update_username.rb +++ b/app/jobs/regular/update_username.rb @@ -1,44 +1,98 @@ module Jobs class UpdateUsername < Jobs::Base + sidekiq_options queue: 'low' + def execute(args) @user_id = args[:user_id] - - username = args[:old_username] - @raw_mention_regex = /(?:(? e + Discourse.warn_exception(e, message: "Failed to update post with id #{post.id}") end end end def update_revisions PostRevision.where(post_conditions("post_revisions.post_id"), post_condition_args).find_each do |revision| - changed = false - - revision.modifications["raw"]&.each do |raw| - changed |= update_raw!(raw) - end - - if changed - revision.modifications["cooked"].map! { |cooked| update_cooked(cooked) } - revision.save! + begin + if revision.modifications.key?("raw") || revision.modifications.key?("cooked") + revision.modifications["raw"]&.map! { |raw| update_raw(raw) } + revision.modifications["cooked"]&.map! { |cooked| update_cooked(cooked) } + revision.save! + end + rescue => e + Discourse.warn_exception(e, message: "Failed to update post revision with id #{revision.id}") end end end + def update_notifications + params = { + user_id: @user_id, + old_username: @old_username, + new_username: @new_username + } + + Notification.exec_sql(<<~SQL, params) + UPDATE notifications + SET data = (data :: JSONB || + jsonb_strip_nulls( + jsonb_build_object( + 'original_username', CASE data :: JSONB ->> 'original_username' + WHEN :old_username + THEN :new_username + ELSE NULL END, + 'display_username', CASE data :: JSONB ->> 'display_username' + WHEN :old_username + THEN :new_username + ELSE NULL END, + 'username', CASE data :: JSONB ->> 'username' + WHEN :old_username + THEN :new_username + ELSE NULL END, + 'username2', CASE data :: JSONB ->> 'username2' + WHEN :old_username + THEN :new_username + ELSE NULL END + ) + )) :: JSON + WHERE data ILIKE '%' || :old_username || '%' + SQL + end + + def update_post_custom_fields + PostCustomField.exec_sql(<<~SQL, old_username: @old_username, new_username: @new_username) + UPDATE post_custom_fields + SET value = :new_username + WHERE name = 'action_code_who' AND value = :old_username + SQL + end + protected def post_conditions(post_id_column) @@ -63,11 +117,9 @@ module Jobs { mentioned: UserAction::MENTION, user_id: @user_id } end - def update_raw!(raw) - changed = false - changed |= raw.gsub!(@raw_mention_regex, "@#{@new_username}") - changed |= raw.gsub!(@raw_quote_regex, "\\1#{@new_username}\\2") - changed + def update_raw(raw) + raw.gsub(@raw_mention_regex, "@#{@new_username}") + .gsub(@raw_quote_regex, "\\1#{@new_username}\\2") end # Uses Nokogiri instead of rebake, because it works for posts and revisions @@ -78,14 +130,14 @@ module Jobs doc.css("a.mention").each do |a| a.content = a.content.gsub(@cooked_mention_username_regex, "@#{@new_username}") - a["href"] = a["href"].gsub(@cooked_mention_user_path_regex, "/u/#{@new_username}") + a["href"] = a["href"].gsub(@cooked_mention_user_path_regex, "/u/#{@new_username}") if a["href"] end doc.css("aside.quote > div.title").each do |div| - # TODO Update avatar URL div.children.each do |child| child.content = child.content.gsub(@cooked_quote_username_regex, @new_username) if child.text? end + div.at_css("img.avatar")&.replace(@avatar_img) end doc.to_html diff --git a/app/jobs/scheduled/dashboard_stats.rb b/app/jobs/scheduled/dashboard_stats.rb index 9556bd7ea1..d475075c4d 100644 --- a/app/jobs/scheduled/dashboard_stats.rb +++ b/app/jobs/scheduled/dashboard_stats.rb @@ -14,6 +14,8 @@ module Jobs GroupMessage.create(Group[:admins].name, :dashboard_problems, limit_once_per: 7.days.to_i) end + # TODO: decide if we want to keep caching this every 30 minutes + AdminDashboardNextData.refresh_stats AdminDashboardData.refresh_stats end end diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index 494fcf2a70..17f210f240 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -87,7 +87,7 @@ module Jobs private def parsed_feed - raw_feed = fetch_rss + raw_feed = fetch_rss.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") return nil if raw_feed.blank? if SiteSetting.embed_username_key_from_feed.present? diff --git a/app/jobs/scheduled/purge_inactive.rb b/app/jobs/scheduled/purge_unactivated.rb similarity index 70% rename from app/jobs/scheduled/purge_inactive.rb rename to app/jobs/scheduled/purge_unactivated.rb index 21417bef21..992b1bddc8 100644 --- a/app/jobs/scheduled/purge_inactive.rb +++ b/app/jobs/scheduled/purge_unactivated.rb @@ -1,5 +1,5 @@ module Jobs - class PurgeInactive < Jobs::Scheduled + class PurgeUnactived < Jobs::Scheduled every 1.day def execute(args) diff --git a/app/models/admin_dashboard_next_data.rb b/app/models/admin_dashboard_next_data.rb index 4fff1a40dc..335087925f 100644 --- a/app/models/admin_dashboard_next_data.rb +++ b/app/models/admin_dashboard_next_data.rb @@ -1,7 +1,14 @@ class AdminDashboardNextData include StatsCacheable - REPORTS = [ "visits", "posts", "time_to_first_response", "likes", "flags" ] + REPORTS = %w{ + page_view_total_reqs + visits + time_to_first_response + likes + flags + user_to_user_private_messages_with_replies + } def initialize(opts = {}) @opts = opts diff --git a/app/models/concerns/date_groupable.rb b/app/models/concerns/date_groupable.rb deleted file mode 100644 index 03cdae08cf..0000000000 --- a/app/models/concerns/date_groupable.rb +++ /dev/null @@ -1,52 +0,0 @@ -module DateGroupable extend ActiveSupport::Concern - class_methods do - def group_by_day(column) - group_by_unit(:day, column) - end - - def group_by_week(column) - group_by_unit(:week, column) - end - - def group_by_month(column) - group_by_unit(:month, column) - end - - def group_by_quarter(column) - group_by_unit(:quarter, column) - end - - def group_by_year(column) - group_by_unit(:year, column) - end - - def group_by_unit(aggregation_unit, column) - group("date_trunc('#{aggregation_unit}', #{column})::DATE") - .order("date_trunc('#{aggregation_unit}', #{column})::DATE") - end - - def aggregation_unit_for_period(start_date, end_date) - days = (start_date.to_date..end_date.to_date).count - - case - when days <= 40 - return :day - when days <= 210 # 30 weeks - return :week - when days <= 550 # ~18 months - return :month - when days <= 1461 # ~4 years - return :quarter - else - return :year - end - end - - def smart_group_by_date(column, start_date, end_date) - aggregation_unit = aggregation_unit_for_period(start_date, end_date) - - where("#{column} BETWEEN ? AND ?", start_date, end_date) - .group_by_unit(aggregation_unit, column) - end - end -end diff --git a/app/models/concerns/second_factor_manager.rb b/app/models/concerns/second_factor_manager.rb index 096fc00305..9dd00b6158 100644 --- a/app/models/concerns/second_factor_manager.rb +++ b/app/models/concerns/second_factor_manager.rb @@ -27,7 +27,7 @@ module SecondFactorManager last_used = self.user_second_factor.last_used.to_i end - authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 0, last_used) + authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 30, last_used) self.user_second_factor.update!(last_used: DateTime.now) if authenticated !!authenticated end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index bc8988d9de..9cc89bbcdc 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -57,7 +57,8 @@ class DiscourseSingleSignOn < SingleSignOn end # ensure it's not staged anymore - user.staged = false + user.unstage + user.save # if the user isn't new or it's attached to the SSO record we might be overriding username or email unless user.new_record? @@ -190,13 +191,31 @@ class DiscourseSingleSignOn < SingleSignOn ) end + if profile_background_url.present? + Jobs.enqueue(:download_profile_background_from_url, + url: profile_background_url, + user_id: user.id, + is_card_background: false + ) + end + + if card_background_url.present? + Jobs.enqueue(:download_profile_background_from_url, + url: card_background_url, + user_id: user.id, + is_card_background: true + ) + end + user.create_single_sign_on_record!( last_payload: unsigned_payload, external_id: external_id, external_username: username, external_email: email, external_name: name, - external_avatar_url: avatar_url + external_avatar_url: avatar_url, + external_profile_background_url: profile_background_url, + external_card_background_url: card_background_url ) end end @@ -233,10 +252,36 @@ class DiscourseSingleSignOn < SingleSignOn end end + profile_background_missing = user.user_profile.profile_background.blank? || Upload.get_from_url(user.user_profile.profile_background).blank? + if (profile_background_missing || SiteSetting.sso_overrides_profile_background) && profile_background_url.present? + profile_background_changed = sso_record.external_profile_background_url != profile_background_url + if profile_background_changed || profile_background_missing + Jobs.enqueue(:download_profile_background_from_url, + url: profile_background_url, + user_id: user.id, + is_card_background: false + ) + end + end + + card_background_missing = user.user_profile.card_background.blank? || Upload.get_from_url(user.user_profile.card_background).blank? + if (card_background_missing || SiteSetting.sso_overrides_profile_background) && card_background_url.present? + card_background_changed = sso_record.external_card_background_url != card_background_url + if card_background_changed || card_background_missing + Jobs.enqueue(:download_profile_background_from_url, + url: card_background_url, + user_id: user.id, + is_card_background: true + ) + end + end + # change external attributes for sso record sso_record.external_username = username sso_record.external_email = email sso_record.external_name = name sso_record.external_avatar_url = avatar_url + sso_record.external_profile_background_url = profile_background_url + sso_record.external_card_background_url = card_background_url end end diff --git a/app/models/group.rb b/app/models/group.rb index 9f049c8821..53f9d6a3ca 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -598,12 +598,13 @@ class Group < ActiveRecord::Base def name_format_validator self.name.strip! - self.name.downcase! UsernameValidator.perform_validation(self, 'name') || begin - if will_save_change_to_name? && name_was&.downcase != self.name + name_lower = self.name.downcase + + if self.will_save_change_to_name? && self.name_was&.downcase != name_lower existing = Group.exec_sql( - User::USERNAME_EXISTS_SQL, username: self.name + User::USERNAME_EXISTS_SQL, username: name_lower ).values.present? if existing diff --git a/app/models/incoming_link.rb b/app/models/incoming_link.rb index 0b7e7c0ed6..0cc748a9a3 100644 --- a/app/models/incoming_link.rb +++ b/app/models/incoming_link.rb @@ -20,6 +20,7 @@ class IncomingLink < ActiveRecord::Base u = User.select(:id).find_by(username_lower: username.downcase) user_id = u.id if u end + ip_address = opts[:ip_address] if opts[:referer].present? begin @@ -38,6 +39,7 @@ class IncomingLink < ActiveRecord::Base .pluck(:id).first cid = current_user ? (current_user.id) : (nil) + ip_address = nil if cid unless cid && cid == user_id @@ -45,7 +47,7 @@ class IncomingLink < ActiveRecord::Base user_id: user_id, post_id: post_id, current_user_id: cid, - ip_address: opts[:ip_address]) if post_id + ip_address: ip_address) if post_id end end diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb index 5e817e18b2..897bf3e50a 100644 --- a/app/models/incoming_links_report.rb +++ b/app/models/incoming_links_report.rb @@ -1,6 +1,6 @@ class IncomingLinksReport - attr_accessor :type, :data, :y_titles + attr_accessor :type, :data, :y_titles, :start_date, :limit def initialize(type) @type = type @@ -14,7 +14,8 @@ class IncomingLinksReport title: I18n.t("reports.#{self.type}.title"), xaxis: I18n.t("reports.#{self.type}.xaxis"), ytitles: self.y_titles, - data: self.data + data: self.data, + start_date: start_date } end @@ -24,6 +25,10 @@ class IncomingLinksReport # Load the report report = IncomingLinksReport.new(type) + + report.start_date = _opts[:start_date] || 30.days.ago + report.limit = _opts[:limit].to_i if _opts[:limit] + send(report_method, report) report end @@ -33,8 +38,8 @@ class IncomingLinksReport report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") - num_clicks = link_count_per_user - num_topics = topic_count_per_user + num_clicks = link_count_per_user(start_date: report.start_date) + num_topics = topic_count_per_user(start_date: report.start_date) user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) { |sum, v| sum[v.username] = v.id; sum; } report.data = [] num_clicks.each_key do |username| @@ -43,19 +48,19 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.per_user - @per_user_query ||= IncomingLink - .where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', 30.days.ago) + def self.per_user(start_date:) + @per_user_query ||= public_incoming_links + .where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', start_date) .joins(:user) .group('users.username') end - def self.link_count_per_user - per_user.count + def self.link_count_per_user(start_date:) + per_user(start_date: start_date).count end - def self.topic_count_per_user - per_user.joins(:post).count("DISTINCT posts.topic_id") + def self.topic_count_per_user(start_date:) + per_user(start_date: start_date).joins(:post).count("DISTINCT posts.topic_id") end # Return top 10 domains that brought traffic to the site within the last 30 days @@ -64,7 +69,7 @@ class IncomingLinksReport report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") report.y_titles[:num_users] = I18n.t("reports.#{report.type}.num_users") - num_clicks = link_count_per_domain + num_clicks = link_count_per_domain(start_date: report.start_date) num_topics = topic_count_per_domain(num_clicks.keys) report.data = [] num_clicks.each_key do |domain| @@ -73,16 +78,18 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.link_count_per_domain(limit = 10) - IncomingLink.where('incoming_links.created_at > ?', 30.days.ago) + def self.link_count_per_domain(limit: 10, start_date:) + public_incoming_links + .where('incoming_links.created_at > ?', start_date) .joins(incoming_referer: :incoming_domain) .group('incoming_domains.name') .order('count_all DESC') - .limit(limit).count + .limit(limit) + .count end def self.per_domain(domains) - IncomingLink + public_incoming_links .joins(incoming_referer: :incoming_domain) .where('incoming_links.created_at > ? AND incoming_domains.name IN (?)', 30.days.ago, domains) .group('incoming_domains.name') @@ -90,13 +97,13 @@ class IncomingLinksReport def self.topic_count_per_domain(domains) # COUNT(DISTINCT) is slow - per_domain(domains).joins(:post).count("DISTINCT posts.topic_id") + per_domain(domains).count("DISTINCT posts.topic_id") end def self.report_top_referred_topics(report) report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") - num_clicks = link_count_per_topic - num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(10).reverse # take the top 10 + num_clicks = link_count_per_topic(start_date: report.start_date) + num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(report.limit || 10).reverse report.data = [] topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map { |z| z[0] }) num_clicks.each do |topic_id, num_clicks_element| @@ -108,10 +115,16 @@ class IncomingLinksReport report.data end - def self.link_count_per_topic - IncomingLink.joins(:post) - .where('incoming_links.created_at > ? AND topic_id IS NOT NULL', 30.days.ago) + def self.link_count_per_topic(start_date:) + public_incoming_links + .where('incoming_links.created_at > ? AND topic_id IS NOT NULL', start_date) .group('topic_id') .count end + + def self.public_incoming_links + IncomingLink + .joins(post: :topic) + .where("topics.archetype = ?", Archetype.default) + end end diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 5bb6d0f1ef..904bf56bb0 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -8,12 +8,6 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f end end - # If `invite_passthrough_hours` is defined, allow them to re-use the invite link - # to login again. - if invite.redeemed_at && invite.redeemed_at >= SiteSetting.invite_passthrough_hours.hours.ago - return get_existing_user - end - nil end diff --git a/app/models/post.rb b/app/models/post.rb index 4296ee20ac..def180ffe5 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -781,7 +781,7 @@ class Post < ActiveRecord::Base end def create_reply_relationship_with(post) - return if post.nil? + return if post.nil? || self.deleted_at.present? post_reply = post.post_replies.new(reply_id: id) if post_reply.save if Topic.visible_post_types.include?(self.post_type) diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 8e705527cd..3bab03db7b 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -52,13 +52,22 @@ class PostAction < ActiveRecord::Base end def self.update_flagged_posts_count - posts_flagged_count = PostAction.active + flagged_relation = PostAction.active .flags .joins(post: :topic) .where('posts.deleted_at' => nil) .where('topics.deleted_at' => nil) .where('posts.user_id > 0') - .count('DISTINCT posts.id') + .group("posts.id") + + if SiteSetting.min_flags_staff_visibility > 1 + flagged_relation = flagged_relation + .having("count(*) >= ?", SiteSetting.min_flags_staff_visibility) + end + + posts_flagged_count = flagged_relation + .pluck("posts.id") + .count $redis.set('posts_flagged_count', posts_flagged_count) user_ids = User.staff.pluck(:id) @@ -590,7 +599,7 @@ SQL options = { url: post.url, edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts, - flag_reason: I18n.t("flag_reasons.#{post_action_type}"), + flag_reason: I18n.t("flag_reasons.#{post_action_type}", locale: SiteSetting.default_locale), } Jobs.enqueue_in(5.seconds, :send_system_message, diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..00dc2176bb --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,14 @@ +class PushSubscription < ActiveRecord::Base + belongs_to :user +end + +# == Schema Information +# +# Table name: push_subscriptions +# +# id :integer not null, primary key +# user_id :integer not null +# data :string not null +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/models/report.rb b/app/models/report.rb index 856bcbd093..744275b155 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -3,7 +3,8 @@ require_dependency 'topic_subtype' class Report attr_accessor :type, :data, :total, :prev30Days, :start_date, - :end_date, :category_id, :group_id, :labels, :async + :end_date, :category_id, :group_id, :labels, :async, + :prev_period, :facets, :limit, :processing, :average, :percent def self.default_days 30 @@ -16,7 +17,16 @@ class Report end def self.cache_key(report) - "reports:#{report.type}:#{report.start_date.to_date.strftime("%Y%m%d")}:#{report.end_date.to_date.strftime("%Y%m%d")}" + (+"reports:") << + [ + report.type, + report.category_id, + report.start_date.to_date.strftime("%Y%m%d"), + report.end_date.to_date.strftime("%Y%m%d"), + report.group_id, + report.facets, + report.limit + ].map(&:to_s).join(':') end def self.clear_cache @@ -26,21 +36,31 @@ class Report end def as_json(options = nil) + description = I18n.t("reports.#{type}.description", default: "") + { type: type, title: I18n.t("reports.#{type}.title"), xaxis: I18n.t("reports.#{type}.xaxis"), yaxis: I18n.t("reports.#{type}.yaxis"), - description: I18n.t("reports.#{type}.description"), + description: description.presence ? description : nil, data: data, - total: total, - start_date: start_date, - end_date: end_date, + start_date: start_date&.iso8601, + end_date: end_date&.iso8601, category_id: category_id, group_id: group_id, prev30Days: self.prev30Days, - labels: labels + report_key: Report.cache_key(self), + labels: labels, + processing: self.processing, + average: self.average, + percent: self.percent }.tap do |json| + json[:total] = total if total + json[:prev_period] = prev_period if prev_period + json[:prev30Days] = self.prev30Days if self.prev30Days + json[:limit] = self.limit if self.limit + if type == 'page_view_crawler_reqs' json[:related_report] = Report.find('web_crawlers', start_date: start_date, end_date: end_date)&.as_json end @@ -51,7 +71,7 @@ class Report singleton_class.instance_eval { define_method("report_#{name}", &block) } end - def self.find(type, opts = nil) + def self._get(type, opts = nil) opts ||= {} # Load the report @@ -60,20 +80,30 @@ class Report report.end_date = opts[:end_date] if opts[:end_date] report.category_id = opts[:category_id] if opts[:category_id] report.group_id = opts[:group_id] if opts[:group_id] - report.async = opts[:async] || false + report.facets = opts[:facets] || [:total, :prev30Days] + report.limit = opts[:limit] if opts[:limit] + report.processing = false + report.average = opts[:average] || false + report.percent = opts[:percent] || false + + report + end + + def self.find_cached(type, opts = nil) + report = _get(type, opts) + Discourse.cache.read(cache_key(report)) + end + + def self.cache(report, duration) + Discourse.cache.write(Report.cache_key(report), report.as_json, force: true, expires_in: duration) + end + + def self.find(type, opts = nil) + report = _get(type, opts) report_method = :"report_#{type}" if respond_to?(report_method) - cached_report = Discourse.cache.read(cache_key(report)) - if report.async - if cached_report - return cached_report - else - Jobs.enqueue(:retrieve_report, opts.merge(report_type: type)) - end - else - send(report_method, report) - end + send(report_method, report) elsif type =~ /_reqs$/ req_report(report, type.split(/_reqs$/)[0].to_sym) else @@ -132,30 +162,24 @@ class Report end end - def self.report_inactive_users(report) - report.data = [] - - data = User.real.count_by_inactivity(report.start_date, report.end_date) - - data.each do |data_point| - report.data << { x: data_point["date"], y: data_point["count"] } - end - - unless report.data.blank? - report.prev30Days = report.data.first[:y] - report.total = report.data.last[:y] - end - end - def self.report_new_contributors(report) report.data = [] data = User.real.count_by_first_post(report.start_date, report.end_date) - prev30DaysData = User.real.count_by_first_post(report.start_date - 30.days, report.start_date) - report.prev30Days = prev30DaysData.sum { |k, v| v } + if report.facets.include?(:prev30Days) + prev30DaysData = User.real.count_by_first_post(report.start_date - 30.days, report.start_date) + report.prev30Days = prev30DaysData.sum { |k, v| v } + end - report.total = User.real.count_by_first_post + if report.facets.include?(:total) + report.total = User.real.count_by_first_post + end + + if report.facets.include?(:prev_period) + prev_period_data = User.real.count_by_first_post(report.start_date - (report.end_date - report.start_date), report.start_date) + report.prev_period = prev_period_data.sum { |k, v| v } + end data.each do |key, value| report.data << { x: key, y: value } @@ -163,14 +187,30 @@ class Report end def self.report_daily_engaged_users(report) + report.average = true + report.data = [] data = UserAction.count_daily_engaged_users(report.start_date, report.end_date) - prev30DaysData = UserAction.count_daily_engaged_users(report.start_date - 30.days, report.start_date) - report.total = UserAction.count_daily_engaged_users + if report.facets.include?(:prev30Days) + prev30DaysData = UserAction.count_daily_engaged_users(report.start_date - 30.days, report.start_date) + report.prev30Days = prev30DaysData.sum { |k, v| v } + end - report.prev30Days = prev30DaysData.sum { |k, v| v } + if report.facets.include?(:total) + report.total = UserAction.count_daily_engaged_users + end + + if report.facets.include?(:prev_period) + prev_data = UserAction.count_daily_engaged_users(report.start_date - (report.end_date - report.start_date), report.start_date) + + prev = prev_data.sum { |k, v| v } + if prev > 0 + prev = prev / ((report.end_date - report.start_date) / 1.day) + end + report.prev_period = prev + end data.each do |key, value| report.data << { x: key, y: value } @@ -178,6 +218,9 @@ class Report end def self.report_dau_by_mau(report) + report.average = true + report.percent = true + data_points = UserVisit.count_by_active_users(report.start_date, report.end_date) report.data = [] @@ -186,7 +229,15 @@ class Report if data_point["mau"] == 0 0 else - ((data_point["dau"].to_f / data_point["mau"].to_f) * 100).ceil + ((data_point["dau"].to_f / data_point["mau"].to_f) * 100).ceil(2) + end + } + + dau_avg = Proc.new { |start_date, end_date| + data_points = UserVisit.count_by_active_users(start_date, end_date) + if !data_points.empty? + sum = data_points.sum { |data_point| compute_dau_by_mau.call(data_point) } + (sum.to_f / data_points.count.to_f).ceil(2) end } @@ -194,10 +245,12 @@ class Report report.data << { x: data_point["date"], y: compute_dau_by_mau.call(data_point) } end - prev_data_points = UserVisit.count_by_active_users(report.start_date - 30.days, report.start_date) - if !prev_data_points.empty? - sum = prev_data_points.sum { |data_point| compute_dau_by_mau.call(data_point) } - report.prev30Days = sum / prev_data_points.count + if report.facets.include?(:prev_period) + report.prev_period = dau_avg.call(report.start_date - (report.end_date - report.start_date), report.start_date) + end + + if report.facets.include?(:prev30Days) + report.prev30Days = dau_avg.call(report.start_date - 30.days, report.start_date) end end @@ -260,14 +313,29 @@ class Report end def self.add_counts(report, subject_class, query_column = 'created_at') - report.total = subject_class.count - report.prev30Days = subject_class.where("#{query_column} >= ? and #{query_column} < ?", report.start_date - 30.days, report.start_date).count + if report.facets.include?(:prev_period) + report.prev_period = subject_class + .where("#{query_column} >= ? and #{query_column} < ?", + (report.start_date - (report.end_date - report.start_date)), + report.start_date).count + end + + if report.facets.include?(:total) + report.total = subject_class.count + end + + if report.facets.include?(:prev30Days) + report.prev30Days = subject_class + .where("#{query_column} >= ? and #{query_column} < ?", + report.start_date - 30.days, + report.start_date).count + end end def self.report_users_by_trust_level(report) report.data = [] - User.real.group('trust_level').count.each do |level, count| - report.data << { x: level.to_i, y: count } + User.real.group('trust_level').count.sort.each do |level, count| + report.data << { key: TrustLevel.levels[level.to_i], x: level.to_i, y: count } end end @@ -344,39 +412,61 @@ class Report label = Proc.new { |key| I18n.t("reports.users_by_type.xaxis_labels.#{key}") } admins = User.real.admins.count - report.data << { x: label.call("admin"), y: admins } if admins > 0 + report.data << { key: "admins", x: label.call("admin"), y: admins } if admins > 0 moderators = User.real.moderators.count - report.data << { x: label.call("moderator"), y: moderators } if moderators > 0 + report.data << { key: "moderators", x: label.call("moderator"), y: moderators } if moderators > 0 suspended = User.real.suspended.count - report.data << { x: label.call("suspended"), y: suspended } if suspended > 0 + report.data << { key: "suspended", x: label.call("suspended"), y: suspended } if suspended > 0 silenced = User.real.silenced.count - report.data << { x: label.call("silenced"), y: silenced } if silenced > 0 + report.data << { key: "silenced", x: label.call("silenced"), y: silenced } if silenced > 0 + end + + def self.report_top_referred_topics(report) + report.labels = [I18n.t("reports.top_referred_topics.xaxis"), + I18n.t("reports.top_referred_topics.num_clicks")] + result = IncomingLinksReport.find(:top_referred_topics, start_date: 7.days.ago, limit: report.limit) + report.data = result.data end def self.report_trending_search(report) report.data = [] - trends = SearchLog.select("term, - COUNT(*) AS searches, - SUM(CASE + select_sql = <<~SQL + lower(term) term, + COUNT(*) AS searches, + SUM(CASE WHEN search_result_id IS NOT NULL THEN 1 ELSE 0 END) AS click_through, - COUNT(DISTINCT ip_address) AS unique") - .where('created_at > ? AND created_at <= ?', report.start_date, report.end_date) - .group(:term) - .order('COUNT(DISTINCT ip_address) DESC, COUNT(*) DESC') - .limit(20).to_a + COUNT(DISTINCT ip_address) AS unique_searches + SQL - report.labels = [:term, :searches, :unique].map { |key| + trends = SearchLog.select(select_sql) + .where('created_at > ? AND created_at <= ?', report.start_date, report.end_date) + .group('lower(term)') + .order('unique_searches DESC, click_through ASC, term ASC') + .limit(report.limit || 20).to_a + + report.labels = [:term, :searches, :click_through].map { |key| I18n.t("reports.trending_search.labels.#{key}") } trends.each do |trend| - report.data << [trend.term, trend.searches, trend.unique] + ctr = + if trend.click_through == 0 || trend.searches == 0 + 0 + else + trend.click_through.to_f / trend.searches.to_f + end + + report.data << { + term: trend.term, + unique_searches: trend.unique_searches, + ctr: (ctr * 100).ceil(1).to_s + "%" + } end end end diff --git a/app/models/single_sign_on_record.rb b/app/models/single_sign_on_record.rb index d556a208bc..1ceeacf5d6 100644 --- a/app/models/single_sign_on_record.rb +++ b/app/models/single_sign_on_record.rb @@ -8,16 +8,18 @@ end # # Table name: single_sign_on_records # -# id :integer not null, primary key -# user_id :integer not null -# external_id :string not null -# last_payload :text not null -# created_at :datetime not null -# updated_at :datetime not null -# external_username :string -# external_email :string -# external_name :string -# external_avatar_url :string(1000) +# id :integer not null, primary key +# user_id :integer not null +# external_id :string not null +# last_payload :text not null +# created_at :datetime not null +# updated_at :datetime not null +# external_username :string +# external_email :string +# external_name :string +# external_avatar_url :string(1000) +# external_profile_background_url :string +# external_card_background_url :string # # Indexes # diff --git a/app/models/tag.rb b/app/models/tag.rb index 8fcba76138..62aee017d3 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -20,22 +20,40 @@ class Tag < ActiveRecord::Base after_commit :trigger_tag_destroyed_event, on: :destroy def self.ensure_consistency! - update_topic_counts # topic_count counter cache can miscount + update_topic_counts end def self.update_topic_counts - Category.exec_sql <<~SQL + Tag.exec_sql <<~SQL UPDATE tags t - SET topic_count = x.topic_count - FROM ( - SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id - FROM tags - LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id - LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message' - GROUP BY tags.id - ) x - WHERE x.tag_id = t.id - AND x.topic_count <> t.topic_count + SET topic_count = x.topic_count + FROM ( + SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id + FROM tags + LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id + LEFT JOIN topics ON topics.id = topic_tags.topic_id + AND topics.deleted_at IS NULL + AND topics.archetype != 'private_message' + GROUP BY tags.id + ) x + WHERE x.tag_id = t.id + AND x.topic_count <> t.topic_count + SQL + + Tag.exec_sql <<~SQL + UPDATE tags t + SET pm_topic_count = x.pm_topic_count + FROM ( + SELECT COUNT(topics.id) AS pm_topic_count, tags.id AS tag_id + FROM tags + LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id + LEFT JOIN topics ON topics.id = topic_tags.topic_id + AND topics.deleted_at IS NULL + AND topics.archetype = 'private_message' + GROUP BY tags.id + ) x + WHERE x.tag_id = t.id + AND x.pm_topic_count <> t.pm_topic_count SQL end @@ -54,7 +72,7 @@ class Tag < ActiveRecord::Base tag_names_with_counts = Tag.exec_sql <<~SQL SELECT tags.name as tag_name, SUM(stats.topic_count) AS sum_topic_count FROM category_tag_stats stats - INNER JOIN tags ON stats.tag_id = tags.id AND stats.topic_count > 0 + JOIN tags ON stats.tag_id = tags.id AND stats.topic_count > 0 WHERE stats.category_id in (#{scope_category_ids.join(',')}) #{filter_sql} GROUP BY tags.name @@ -71,23 +89,24 @@ class Tag < ActiveRecord::Base user_id = allowed_user.id tag_names_with_counts = Tag.exec_sql <<~SQL - SELECT tags.name, - COUNT(topics.id) AS topic_count - FROM tags - INNER JOIN topic_tags ON tags.id = topic_tags.tag_id - INNER JOIN topics ON topics.id = topic_tags.topic_id - AND topics.deleted_at IS NULL - AND topics.archetype = 'private_message' - WHERE topic_tags.topic_id IN - (SELECT topic_id - FROM topic_allowed_users - WHERE user_id = #{user_id} - UNION ALL SELECT tg.topic_id - FROM topic_allowed_groups tg - JOIN group_users gu ON gu.user_id = #{user_id} - AND gu.group_id = tg.group_id) - GROUP BY tags.name - LIMIT #{limit} + SELECT tags.name, COUNT(topics.id) AS topic_count + FROM tags + JOIN topic_tags ON tags.id = topic_tags.tag_id + JOIN topics ON topics.id = topic_tags.topic_id + AND topics.deleted_at IS NULL + AND topics.archetype = 'private_message' + WHERE topic_tags.topic_id IN ( + SELECT topic_id + FROM topic_allowed_users + WHERE user_id = #{user_id} + UNION + SELECT tg.topic_id + FROM topic_allowed_groups tg + JOIN group_users gu ON gu.user_id = #{user_id} + AND gu.group_id = tg.group_id + ) + GROUP BY tags.name + LIMIT #{limit} SQL tag_names_with_counts.map { |t| { id: t['name'], text: t['name'], count: t['topic_count'] } } @@ -120,11 +139,12 @@ end # # Table name: tags # -# id :integer not null, primary key -# name :string not null -# topic_count :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# name :string not null +# topic_count :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# pm_topic_count :integer default(0), not null # # Indexes # diff --git a/app/models/tag_group.rb b/app/models/tag_group.rb index cc556a2a90..5a5a74af02 100644 --- a/app/models/tag_group.rb +++ b/app/models/tag_group.rb @@ -39,9 +39,6 @@ class TagGroup < ActiveRecord::Base mapped = permissions.map do |group, permission| group_id = Group.group_id_from_param(group) permission = TagGroupPermission.permission_types[permission] unless permission.is_a?(Integer) - - return [] if group_id == everyone_group_id && permission == full - [group_id, permission] end end @@ -65,27 +62,21 @@ class TagGroup < ActiveRecord::Base end end - def self.allowed(guardian) + def self.visible(guardian) if guardian.is_staff? TagGroup else - category_permissions_filter_sql = <<~SQL - (id IN ( SELECT tag_group_id FROM category_tag_groups WHERE category_id IN (?)) - OR id NOT IN (SELECT tag_group_id FROM category_tag_groups)) - AND id IN ( - SELECT tag_group_id - FROM tag_group_permissions - WHERE group_id = ? - AND permission_type = ? + filter_sql = <<~SQL + ( + id IN (SELECT tag_group_id FROM category_tag_groups WHERE category_id IN (?)) + ) OR ( + id NOT IN (SELECT tag_group_id FROM category_tag_groups) + AND + id IN (SELECT tag_group_id FROM tag_group_permissions WHERE group_id = ?) ) SQL - TagGroup.where( - category_permissions_filter_sql, - guardian.allowed_category_ids, - Group::AUTO_GROUPS[:everyone], - TagGroupPermission.permission_types[:full] - ) + TagGroup.where(filter_sql, guardian.allowed_category_ids, Group::AUTO_GROUPS[:everyone]) end end end diff --git a/app/models/topic.rb b/app/models/topic.rb index b8be34167e..4ee0068001 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -21,7 +21,6 @@ class Topic < ActiveRecord::Base include Searchable include LimitedEdit extend Forwardable - include DateGroupable def_delegator :featured_users, :user_ids, :featured_user_ids def_delegator :featured_users, :choose, :feature_topic_users @@ -459,7 +458,8 @@ class Topic < ActiveRecord::Base end def self.listable_count_per_day(start_date, end_date, category_id = nil) - result = listable_topics.smart_group_by_date("topics.created_at", start_date, end_date) + result = listable_topics.where("topics.created_at >= ? AND topics.created_at <= ?", start_date, end_date) + result = result.group('date(topics.created_at)').order('date(topics.created_at)') result = result.where(category_id: category_id) if category_id result.count end @@ -662,12 +662,26 @@ SQL if self.category_id != new_category.id self.update!(category_id: new_category.id) - Category.where(id: old_category.id).update_all("topic_count = topic_count - 1") if old_category + + if old_category + Category + .where(id: old_category.id) + .update_all("topic_count = topic_count - 1") + end # when a topic changes category we may have to start watching it # if we happen to have read state for it CategoryUser.auto_watch(category_id: new_category.id, topic_id: self.id) CategoryUser.auto_track(category_id: new_category.id, topic_id: self.id) + + post = self.ordered_posts.first + + if post + PostAlerter.new.notify_post_users( + post, + [post.user, post.last_editor].uniq + ) + end end Category.where(id: new_category.id).update_all("topic_count = topic_count + 1") diff --git a/app/models/topic_tag.rb b/app/models/topic_tag.rb index 8c0a8df501..22edeebad0 100644 --- a/app/models/topic_tag.rb +++ b/app/models/topic_tag.rb @@ -3,26 +3,34 @@ class TopicTag < ActiveRecord::Base belongs_to :tag after_create do - if topic && topic.archetype != Archetype.private_message - tag.increment!(:topic_count) + if topic + if topic.archetype == Archetype.private_message + tag.increment!(:pm_topic_count) + else + tag.increment!(:topic_count) - if topic.category_id - if stat = CategoryTagStat.where(tag_id: tag_id, category_id: topic.category_id).first - stat.increment!(:topic_count) - else - CategoryTagStat.create(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) + if topic.category_id + if stat = CategoryTagStat.find_by(tag_id: tag_id, category_id: topic.category_id) + stat.increment!(:topic_count) + else + CategoryTagStat.create(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) + end end end end end after_destroy do - if topic && topic.archetype != Archetype.private_message - if topic.category_id && stat = CategoryTagStat.where(tag_id: tag_id, category: topic.category_id).first - stat.topic_count == 1 ? stat.destroy : stat.decrement!(:topic_count) - end + if topic + if topic.archetype == Archetype.private_message + tag.decrement!(:pm_topic_count) + else + if topic.category_id && stat = CategoryTagStat.find_by(tag_id: tag_id, category: topic.category_id) + stat.topic_count == 1 ? stat.destroy : stat.decrement!(:topic_count) + end - tag.decrement!(:topic_count) + tag.decrement!(:topic_count) + end end end end diff --git a/app/models/user.rb b/app/models/user.rb index 9989bff0cf..765050e92f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,7 +19,6 @@ class User < ActiveRecord::Base include Roleable include HasCustomFields include SecondFactorManager - include DateGroupable # TODO: Remove this after 7th Jan 2018 self.ignored_columns = %w{email} @@ -80,6 +79,8 @@ class User < ActiveRecord::Base has_one :api_key, dependent: :destroy + has_many :push_subscriptions, dependent: :destroy + belongs_to :uploaded_avatar, class_name: 'Upload' has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory' @@ -267,15 +268,20 @@ class User < ActiveRecord::Base user end + def unstage + if self.staged + self.staged = false + self.custom_fields[FROM_STAGED] = true + self.notifications.destroy_all + DiscourseEvent.trigger(:user_unstaged, self) + end + end + def self.unstage(params) if user = User.where(staged: true).with_email(params[:email].strip.downcase).first params.each { |k, v| user.send("#{k}=", v) } - user.staged = false user.active = false - user.custom_fields[FROM_STAGED] = true - user.notifications.destroy_all - - DiscourseEvent.trigger(:user_unstaged, user) + user.unstage end user end @@ -776,7 +782,9 @@ class User < ActiveRecord::Base end def email_confirmed? - email_tokens.where(email: email, confirmed: true).present? || email_tokens.empty? + email_tokens.where(email: email, confirmed: true).present? || + email_tokens.empty? || + single_sign_on_record&.external_email == email end def activate @@ -797,8 +805,7 @@ class User < ActiveRecord::Base end def readable_name - return "#{name} (#{username})" if name.present? && name != username - username + name.present? && name != username ? "#{name} (#{username})" : username end def badge_count @@ -829,56 +836,13 @@ class User < ActiveRecord::Base (tl_badge + other_badges).take(limit) end - def self.count_by_inactivity(start_date, end_date) - aggregation_unit = aggregation_unit_for_period(start_date, end_date) - - sql = <<~SQL - SELECT - date_trunc('#{aggregation_unit}', generated_date) :: DATE AS "date", - max("count") AS "count" - FROM ( - SELECT - d.generated_date, - COUNT(1) AS "count" - FROM (SELECT generate_series(:start_date, :end_date, '1 day' :: INTERVAL) :: DATE AS generated_date) d - JOIN users u ON (u.created_at :: DATE <= d.generated_date) - WHERE u.active AND - u.id > 0 AND - NOT EXISTS( - SELECT 1 - FROM user_custom_fields ucf - WHERE - ucf.user_id = u.id AND - ucf.name = 'master_id' AND - ucf.value :: int > 0 - ) AND - NOT EXISTS( - SELECT 1 - FROM user_visits v - WHERE v.visited_at BETWEEN (d.generated_date - INTERVAL '89 days') :: DATE AND d.generated_date - AND v.user_id = u.id - ) AND - NOT EXISTS( - SELECT 1 - FROM incoming_emails e - WHERE e.user_id = u.id AND - e.post_id IS NOT NULL AND - e.created_at :: DATE BETWEEN (d.generated_date - INTERVAL '89 days') :: DATE AND d.generated_date - ) - GROUP BY d.generated_date - ) AS x - GROUP BY date_trunc('#{aggregation_unit}', generated_date) :: DATE - ORDER BY date_trunc('#{aggregation_unit}', generated_date) :: DATE - SQL - - exec_sql(sql, start_date: start_date, end_date: end_date).to_a - end - def self.count_by_signup_date(start_date = nil, end_date = nil, group_id = nil) result = self if start_date && end_date - result = result.smart_group_by_date("users.created_at", start_date, end_date) + result = result.group("date(users.created_at)") + result = result.where("users.created_at >= ? AND users.created_at <= ?", start_date, end_date) + result = result.order('date(users.created_at)') end if group_id @@ -893,7 +857,9 @@ class User < ActiveRecord::Base result = joins('INNER JOIN user_stats AS us ON us.user_id = users.id') if start_date && end_date - result = result.smart_group_by_date("us.first_post_created_at", start_date, end_date) + result = result.group("date(us.first_post_created_at)") + result = result.where("us.first_post_created_at > ? AND us.first_post_created_at < ?", start_date, end_date) + result = result.order("date(us.first_post_created_at)") end result.count @@ -1068,7 +1034,7 @@ class User < ActiveRecord::Base end def set_automatic_groups - return unless active && email_confirmed? && !staged + return if !active || staged || !email_confirmed? Group.where(automatic: false) .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") @@ -1232,22 +1198,22 @@ class User < ActiveRecord::Base end end - # Delete unactivated accounts (without verified email) that are over a week old def self.purge_unactivated return [] if SiteSetting.purge_unactivated_users_grace_period_days <= 0 - to_destroy = User.where(active: false) - .joins('INNER JOIN user_stats AS us ON us.user_id = users.id') - .where("created_at < ?", SiteSetting.purge_unactivated_users_grace_period_days.days.ago) - .where('NOT admin AND NOT moderator') - .limit(200) - destroyer = UserDestroyer.new(Discourse.system_user) - to_destroy.each do |u| + + User + .where(active: false) + .where("created_at < ?", SiteSetting.purge_unactivated_users_grace_period_days.days.ago) + .where("NOT admin AND NOT moderator") + .where("NOT EXISTS (SELECT 1 FROM topic_allowed_users WHERE user_id = users.id LIMIT 1)") + .limit(200) + .find_each do |user| begin - destroyer.destroy(u, context: I18n.t(:purge_reason)) + destroyer.destroy(user, context: I18n.t(:purge_reason)) rescue Discourse::InvalidAccess, UserDestroyer::PostsExistError - # if for some reason the user can't be deleted, continue on to the next one + # keep going end end end diff --git a/app/models/user_action.rb b/app/models/user_action.rb index 22b27b8cb1..20a3acde6c 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -1,5 +1,4 @@ class UserAction < ActiveRecord::Base - include DateGroupable belongs_to :user belongs_to :target_post, class_name: "Post" @@ -127,7 +126,9 @@ SQL .where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE]) if start_date && end_date - result = result.smart_group_by_date(:created_at, start_date, end_date) + result = result.group('date(created_at)') + result = result.where('created_at > ? AND created_at < ?', start_date, end_date) + result = result.order('date(created_at)') end result.count diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 825b945107..721d04196a 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -1,3 +1,4 @@ +require_dependency 'upload_creator' class UserProfile < ActiveRecord::Base # TODO: remove this after Nov 1, 2018 @@ -78,6 +79,36 @@ class UserProfile < ActiveRecord::Base update_columns(bio_cooked: cooked, bio_cooked_version: BAKED_VERSION) end + def self.import_url_for_user(background_url, user, options = nil) + tempfile = FileHelper.download( + background_url, + max_file_size: SiteSetting.max_image_size_kb.kilobytes, + tmp_file_name: "sso-profile-background", + follow_redirect: true + ) + + return unless tempfile + + ext = FastImage.type(tempfile).to_s + tempfile.rewind + + is_card_background = !options || options[:is_card_background] + type = is_card_background ? "card_background" : "profile_background" + + upload = UploadCreator.new(tempfile, "external-profile-background." + ext, origin: background_url, type: type).create_for(user.id) + + if (is_card_background) + user.user_profile.upload_card_background(upload) + else + user.user_profile.upload_profile_background(upload) + end + + rescue Net::ReadTimeout, OpenURI::HTTPError + # skip saving, we are not connected to the net + ensure + tempfile.close! if tempfile && tempfile.respond_to?(:close!) + end + protected def trigger_badges diff --git a/app/models/user_visit.rb b/app/models/user_visit.rb index be4bd917c1..52bd2fab9c 100644 --- a/app/models/user_visit.rb +++ b/app/models/user_visit.rb @@ -1,6 +1,4 @@ class UserVisit < ActiveRecord::Base - include DateGroupable - def self.counts_by_day_query(start_date, end_date, group_id = nil) result = where('visited_at >= ? and visited_at <= ?', start_date.to_date, end_date.to_date) @@ -13,16 +11,14 @@ class UserVisit < ActiveRecord::Base end def self.count_by_active_users(start_date, end_date) - aggregation_unit = aggregation_unit_for_period(start_date, end_date) - sql = <= :start_date::DATE AND user_visits.visited_at <= :end_date::DATE + GROUP BY date_trunc('day', user_visits.visited_at)::DATE + ORDER BY date_trunc('day', user_visits.visited_at)::DATE ) SELECT date, dau, @@ -33,7 +29,7 @@ class UserVisit < ActiveRecord::Base FROM dau SQL - UserVisit.exec_sql(sql).to_a + UserVisit.exec_sql(sql, start_date: start_date, end_date: end_date).to_a end # A count of visits in a date range by day diff --git a/app/serializers/admin_plugin_serializer.rb b/app/serializers/admin_plugin_serializer.rb index b104d69adb..b115071496 100644 --- a/app/serializers/admin_plugin_serializer.rb +++ b/app/serializers/admin_plugin_serializer.rb @@ -6,7 +6,8 @@ class AdminPluginSerializer < ApplicationSerializer :admin_route, :enabled, :enabled_setting, - :is_official + :is_official, + :enabled_setting_filter def id object.metadata.name @@ -28,12 +29,20 @@ class AdminPluginSerializer < ApplicationSerializer object.enabled? end + def include_enabled_setting? + enabled_setting.present? + end + def enabled_setting object.enabled_site_setting end - def include_enabled_setting? - enabled_setting.present? + def include_enabled_setting_filter? + object.enabled_site_setting_filter.present? + end + + def enabled_setting_filter + object.enabled_site_setting_filter end def include_url? diff --git a/app/serializers/single_sign_on_record_serializer.rb b/app/serializers/single_sign_on_record_serializer.rb index a70591531f..d2e35119b1 100644 --- a/app/serializers/single_sign_on_record_serializer.rb +++ b/app/serializers/single_sign_on_record_serializer.rb @@ -3,5 +3,7 @@ class SingleSignOnRecordSerializer < ApplicationSerializer :last_payload, :created_at, :updated_at, :external_username, :external_email, :external_name, - :external_avatar_url + :external_avatar_url, + :external_profile_background_url, + :external_card_background_url end diff --git a/app/services/post_action_notifier.rb b/app/services/post_action_notifier.rb index b9d1997996..a25524b543 100644 --- a/app/services/post_action_notifier.rb +++ b/app/services/post_action_notifier.rb @@ -97,6 +97,7 @@ class PostActionNotifier return unless post return if post_revision.user.blank? return if post_revision.user_id == post.user_id + return if post.topic.blank? return if post.topic.private_message? return if SiteSetting.disable_edit_notifications && post_revision.user_id == Discourse::SYSTEM_USER_ID diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 2c30ada818..2bc5524cf4 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -3,8 +3,7 @@ require_dependency 'user_action_creator' class PostAlerter def self.post_created(post, opts = {}) - alerter = PostAlerter.new(opts) - alerter.after_save_post(post, true) + PostAlerter.new(opts).after_save_post(post, true) post end @@ -90,30 +89,8 @@ class PostAlerter # private messages if new_record if post.topic.private_message? - # users that aren't part of any mentioned groups - users = directly_targeted_users(post).reject { |u| notified.include?(u) } - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |user| - notification_level = TopicUser.get(post.topic, user).try(:notification_level) - if reply_to_user == user || notification_level == TopicUser.notification_levels[:watching] || user.staged? - create_notification(user, Notification.types[:private_message], post) - end - end - # users that are part of all mentionned groups - users = indirectly_targeted_users(post).reject { |u| notified.include?(u) } - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |user| - # only create a notification when watching the group - notification_level = TopicUser.get(post.topic, user).try(:notification_level) - - if notification_level == TopicUser.notification_levels[:watching] - create_notification(user, Notification.types[:private_message], post) - elsif notification_level == TopicUser.notification_levels[:tracking] - notify_group_summary(user, post) - end - end + notify_pm_users(post, reply_to_user, notified) elsif post.post_type == Post.types[:regular] - # If it's not a private message and it's not an automatic post caused by a moderator action, notify the users notify_post_users(post, notified) end end @@ -133,9 +110,11 @@ class PostAlerter .pluck(:user_id) group_ids = topic.allowed_groups.pluck(:group_id) - group_watchers = GroupUser.where(group_id: group_ids, - notification_level: GroupUser.notification_levels[:watching_first_post]) - .pluck(:user_id) + + group_watchers = GroupUser.where( + group_id: group_ids, + notification_level: GroupUser.notification_levels[:watching_first_post] + ).pluck(:user_id) watchers = [cat_watchers, tag_watchers, group_watchers].flatten @@ -146,18 +125,17 @@ class PostAlerter def notify_first_post_watchers(post, user_ids) return if user_ids.blank? - user_ids.uniq! # Don't notify the OP user_ids -= [post.user_id] - users = User.where(id: user_ids) - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - user_ids.each do |id| - u = User.find_by(id: id) - create_notification(u, Notification.types[:watching_first_post], post) + Scheduler::Defer.later("Notify First Post Watchers") do + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + create_notification(user, Notification.types[:watching_first_post], post) + end end end @@ -223,7 +201,6 @@ class PostAlerter end def notify_group_summary(user, post) - @group_stats ||= {} stats = (@group_stats[post.topic_id] ||= group_stats(post.topic)) return unless stats @@ -263,12 +240,9 @@ class PostAlerter end def should_notify_like?(user, notification) - return true if user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:always] - return true if user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:first_time_and_daily] && notification.created_at < 1.day.ago - - return false + false end def should_notify_previous?(user, notification, opts) @@ -505,59 +479,101 @@ class PostAlerter users = [users] unless users.is_a?(Array) users = users.reject { |u| u.staged? } if post.topic&.private_message? - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |u| - create_notification(u, Notification.types[type], post, opts) + Scheduler::Defer.later("Notify Users") do + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |u| + create_notification(u, Notification.types[type], post, opts) + end end users end + def notify_pm_users(post, reply_to_user, notified) + return unless post.topic + + Scheduler::Defer.later("Notify PM Users") do + # users that aren't part of any mentioned groups + users = directly_targeted_users(post).reject { |u| notified.include?(u) } + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + notification_level = TopicUser.get(post.topic, user)&.notification_level + if reply_to_user == user || notification_level == TopicUser.notification_levels[:watching] || user.staged? + create_notification(user, Notification.types[:private_message], post) + end + end + + # users that are part of all mentionned groups + users = indirectly_targeted_users(post).reject { |u| notified.include?(u) } + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + case TopicUser.get(post.topic, user)&.notification_level + when TopicUser.notification_levels[:watching] + # only create a notification when watching the group + create_notification(user, Notification.types[:private_message], post) + when TopicUser.notification_levels[:tracking] + notify_group_summary(user, post) + end + end + end + end + def notify_post_users(post, notified) return unless post.topic - condition = <

    quoted post

    @@ -288,7 +325,7 @@ describe UsernameChanger do

    quoted post

    @@ -296,7 +333,7 @@ describe UsernameChanger do

    quoted post

    @@ -305,10 +342,182 @@ describe UsernameChanger do HTML end - # TODO spec for quotes in revisions + context 'simple quote' do + let(:raw) do <<~RAW + Lorem ipsum + + [quote="foo, post:1, topic:#{quoted_post.topic.id}"] + quoted post + [/quote] + RAW + end + + let(:expected_raw) do + <<~RAW.strip + Lorem ipsum + + [quote="bar, post:1, topic:#{quoted_post.topic.id}"] + quoted post + [/quote] + RAW + end + + let(:expected_cooked) do + <<~HTML +

    Lorem ipsum

    + + HTML + end + + it 'replaces the username in quote tags when the post is deleted' do + post = create_post_and_change_username(raw: raw) do |p| + PostDestroyer.new(Discourse.system_user, p).destroy + end + + expect(post.raw).to eq(expected_raw) + expect(post.cooked).to match_html(expected_cooked) + end + + it 'replaces the username in quote tags when the post is marked as deleted' do + post = create_post_and_change_username(raw: raw) do |p| + PostDestroyer.new(p.user, p).destroy + end + + expect(post.raw).to_not include("foo") + expect(post.cooked).to_not include("foo") + expect(post.revisions.count).to eq(1) + + expect(post.revisions[0].modifications["raw"][0]).to eq(expected_raw) + expect(post.revisions[0].modifications["cooked"][0]).to match_html(expected_cooked) + end + end + end + + context 'oneboxes' do + let(:quoted_post) { create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") } + let(:avatar_url) { user.avatar_template.gsub("{size}", "40") } + + def protocol_relative_url(url) + url.sub(/^https?:/, '') + end + + it 'updates avatar for linked topics and posts' do + raw = "#{quoted_post.full_url}\n#{quoted_post.topic.url}" + post = create_post_and_change_username(raw: raw) + + expect(post.raw).to eq(raw) + + expect(post.cooked).to match_html(<<~HTML) +

    +
    + +

    + HTML + end + end + + it 'updates username in small action posts' do + invited_by = Fabricate(:user) + p1 = topic.add_small_action(invited_by, 'invited_user', 'foo') + p2 = topic.add_small_action(invited_by, 'invited_user', 'foobar') + UsernameChanger.change(user, 'bar') + + expect(p1.reload.custom_fields['action_code_who']).to eq('bar') + expect(p2.reload.custom_fields['action_code_who']).to eq('foobar') end end + context 'notifications' do + def create_notification(type, notified_user, post, data = {}) + Fabricate( + :notification, + notification_type: Notification.types[type], + user: notified_user, + data: data.to_json, + topic: post&.topic, + post_number: post&.post_number + ) + end + + def notification_data(notification) + JSON.parse(notification.reload.data, symbolize_names: true) + end + + def original_and_display_username(username) + { original_username: username, display_username: username, foo: "bar" } + end + + def original_username_and_some_text_as_display_username(username) + { original_username: username, display_username: "some text", foo: "bar" } + end + + def only_display_username(username) + { display_username: username } + end + + def username_and_something_else(username) + { username: username, foo: "bar" } + end + + it 'replaces usernames in notifications' do + renamed_user = Fabricate(:user, username: "alice") + another_user = Fabricate(:user, username: "another_user") + notified_user = Fabricate(:user) + p1 = Fabricate(:post, post_number: 1, user: renamed_user) + p2 = Fabricate(:post, post_number: 1, user: another_user) + Fabricate(:invite, invited_by: notified_user, user: renamed_user) + Fabricate(:invite, invited_by: notified_user, user: another_user) + + n01 = create_notification(:mentioned, notified_user, p1, original_and_display_username("alice")) + n02 = create_notification(:mentioned, notified_user, p2, original_and_display_username("another_user")) + n03 = create_notification(:mentioned, notified_user, p1, original_username_and_some_text_as_display_username("alice")) + n04 = create_notification(:mentioned, notified_user, p1, only_display_username("alice")) + n05 = create_notification(:invitee_accepted, notified_user, nil, only_display_username("alice")) + n06 = create_notification(:invitee_accepted, notified_user, nil, only_display_username("another_user")) + n07 = create_notification(:granted_badge, renamed_user, nil, username_and_something_else("alice")) + n08 = create_notification(:granted_badge, another_user, nil, username_and_something_else("another_user")) + n09 = create_notification(:group_message_summary, renamed_user, nil, username_and_something_else("alice")) + n10 = create_notification(:group_message_summary, another_user, nil, username_and_something_else("another_user")) + + UsernameChanger.change(renamed_user, "bob") + + expect(notification_data(n01)).to eq(original_and_display_username("bob")) + expect(notification_data(n02)).to eq(original_and_display_username("another_user")) + expect(notification_data(n03)).to eq(original_username_and_some_text_as_display_username("bob")) + expect(notification_data(n04)).to eq(only_display_username("bob")) + expect(notification_data(n05)).to eq(only_display_username("bob")) + expect(notification_data(n06)).to eq(only_display_username("another_user")) + expect(notification_data(n07)).to eq(username_and_something_else("bob")) + expect(notification_data(n08)).to eq(username_and_something_else("another_user")) + expect(notification_data(n09)).to eq(username_and_something_else("bob")) + expect(notification_data(n10)).to eq(username_and_something_else("another_user")) + end + end end end diff --git a/test/javascripts/acceptance/dashboard-next-test.js.es6 b/test/javascripts/acceptance/dashboard-next-test.js.es6 index dd0b0744d2..12eed20c24 100644 --- a/test/javascripts/acceptance/dashboard-next-test.js.es6 +++ b/test/javascripts/acceptance/dashboard-next-test.js.es6 @@ -8,7 +8,7 @@ acceptance("Dashboard Next", { }); QUnit.test("Visit dashboard next page", assert => { - visit("/admin/dashboard-next"); + visit("/admin"); andThen(() => { assert.ok($('.dashboard-next').length, "has dashboard-next class"); diff --git a/test/javascripts/acceptance/group-card-mobile-test.js.es6 b/test/javascripts/acceptance/group-card-mobile-test.js.es6 new file mode 100644 index 0000000000..33204c8962 --- /dev/null +++ b/test/javascripts/acceptance/group-card-mobile-test.js.es6 @@ -0,0 +1,15 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Group Card - Mobile", { mobileView: true }); + +QUnit.test("group card", assert => { + visit('/t/301/1'); + + assert.ok(invisible('#group-card'), 'user card is invisible by default'); + click('a.mention-group:first'); + + andThen(() => { + assert.ok(visible('.group-details-container'), 'group page show be shown'); + }); + +}); diff --git a/test/javascripts/acceptance/group-card-test.js.es6 b/test/javascripts/acceptance/group-card-test.js.es6 new file mode 100644 index 0000000000..15389afd12 --- /dev/null +++ b/test/javascripts/acceptance/group-card-test.js.es6 @@ -0,0 +1,15 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Group Card"); + +QUnit.test("group card", assert => { + visit('/t/301/1'); + + assert.ok(invisible('#group-card'), 'user card is invisible by default'); + click('a.mention-group:first'); + + andThen(() => { + assert.ok(visible('#group-card'), 'card should appear'); + }); + +}); diff --git a/test/javascripts/acceptance/search-test.js.es6 b/test/javascripts/acceptance/search-test.js.es6 index 5929078b28..d388d67787 100644 --- a/test/javascripts/acceptance/search-test.js.es6 +++ b/test/javascripts/acceptance/search-test.js.es6 @@ -69,6 +69,11 @@ QUnit.test("Search with context", assert => { andThen(() => { assert.ok(exists('.search-menu .results ul li'), 'it shows results'); + + assert.ok( + exists('.cooked span.highlight-strong'), + 'it should highlight the search term' + ); }); visit("/"); diff --git a/test/javascripts/acceptance/tags-test.js.es6 b/test/javascripts/acceptance/tags-test.js.es6 index d048154d11..8971f83ee3 100644 --- a/test/javascripts/acceptance/tags-test.js.es6 +++ b/test/javascripts/acceptance/tags-test.js.es6 @@ -24,19 +24,19 @@ QUnit.test("list the tags in groups", assert => { 200, { "Content-Type": "application/json" }, { - "tags":[{id: 'planned', text: 'planned', count: 7}], + "tags":[{id: 'planned', text: 'planned', count: 7, pm_count: 0}], "extras": { "tag_groups": [ {id: 2, name: "Ford Cars", tags: [ - {id: 'escort', text: 'escort', count: 1}, - {id: 'focus', text: 'focus', count: 3} + {id: 'escort', text: 'escort', count: 1, pm_count: 0}, + {id: 'focus', text: 'focus', count: 3, pm_count: 0} ]}, {id: 1, name: "Honda Cars", tags: [ - {id: 'civic', text: 'civic', count: 4}, - {id: 'accord', text: 'accord', count: 2} + {id: 'civic', text: 'civic', count: 4, pm_count: 0}, + {id: 'accord', text: 'accord', count: 2, pm_count: 0} ]}, {id: 1, name: "Makes", tags: [ - {id: 'ford', text: 'ford', count: 5}, - {id: 'honda', text: 'honda', count: 6} + {id: 'ford', text: 'ford', count: 5, pm_count: 0}, + {id: 'honda', text: 'honda', count: 6, pm_count: 0} ]} ]} } diff --git a/test/javascripts/acceptance/topic-edit-timer-test.js.es6 b/test/javascripts/acceptance/topic-edit-timer-test.js.es6 index 569639d810..b3ed4df744 100644 --- a/test/javascripts/acceptance/topic-edit-timer-test.js.es6 +++ b/test/javascripts/acceptance/topic-edit-timer-test.js.es6 @@ -1,4 +1,4 @@ -import { acceptance } from 'helpers/qunit-helpers'; +import { acceptance, replaceCurrentUser } from 'helpers/qunit-helpers'; acceptance('Topic - Edit timer', { loggedIn: true }); QUnit.test('default', assert => { @@ -162,6 +162,22 @@ QUnit.test('schedule', assert => { }); }); +QUnit.test("TL4 can't auto-delete", assert => { + replaceCurrentUser({ staff: false, trust_level: 4 }); + + visit('/t/internationalization-localization'); + click('.toggle-admin-menu'); + click('.topic-admin-status-update button'); + + const timerType = selectKit('.select-kit.timer-type'); + + timerType.expand(); + + andThen(() => { + assert.ok(!timerType.rowByValue("delete").exists()); + }); +}); + QUnit.test('auto delete', assert => { const timerType = selectKit('.select-kit.timer-type'); const futureDateInputSelector = selectKit('.future-date-input-selector'); diff --git a/test/javascripts/acceptance/user-card-test.js.es6 b/test/javascripts/acceptance/user-card-test.js.es6 index e14132d9b7..dcab0c2930 100644 --- a/test/javascripts/acceptance/user-card-test.js.es6 +++ b/test/javascripts/acceptance/user-card-test.js.es6 @@ -1,7 +1,8 @@ import { acceptance } from "helpers/qunit-helpers"; + acceptance("User Card"); -QUnit.test("card", assert => { +QUnit.test("user card", assert => { visit('/'); assert.ok(invisible('#user-card'), 'user card is invisible by default'); @@ -12,16 +13,3 @@ QUnit.test("card", assert => { }); }); - - -QUnit.test("group card", assert => { - visit('/t/301/1'); - - assert.ok(invisible('#group-card'), 'user card is invisible by default'); - click('a.mention-group:first'); - - andThen(() => { - assert.ok(visible('#group-card'), 'card should appear'); - }); - -}); diff --git a/test/javascripts/acceptance/users-test.js.es6 b/test/javascripts/acceptance/users-test.js.es6 index 99d1aff063..648b41890d 100644 --- a/test/javascripts/acceptance/users-test.js.es6 +++ b/test/javascripts/acceptance/users-test.js.es6 @@ -2,12 +2,24 @@ import { acceptance } from "helpers/qunit-helpers"; acceptance("User Directory"); QUnit.test("Visit Page", async assert => { - await visit("/users"); + await visit("/u"); assert.ok($('body.users-page').length, "has the body class"); assert.ok(exists('.directory table tr'), "has a list of users"); }); QUnit.test("Visit All Time", async assert => { - await visit("/users?period=all"); + await visit("/u?period=all"); assert.ok(exists('.time-read'), "has time read column"); }); + +QUnit.test("Visit Without Usernames", async assert => { + await visit("/u?exclude_usernames=system"); + assert.ok($('body.users-page').length, "has the body class"); + assert.ok(exists('.directory table tr'), "has a list of users"); +}); + +QUnit.test("Visit With Group Filter", async assert => { + await visit("/u?group=trust_level_0"); + assert.ok($('body.users-page').length, "has the body class"); + assert.ok(exists('.directory table tr'), "has a list of users"); +}); \ No newline at end of file diff --git a/test/javascripts/fixtures/daily-engaged-users.js.es6 b/test/javascripts/fixtures/daily-engaged-users.js.es6 index 902beb981c..4787c458f3 100644 --- a/test/javascripts/fixtures/daily-engaged-users.js.es6 +++ b/test/javascripts/fixtures/daily-engaged-users.js.es6 @@ -13,7 +13,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": null, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/dau-by-mau.js.es6 b/test/javascripts/fixtures/dau-by-mau.js.es6 index d7ab39d3a0..78a125d952 100644 --- a/test/javascripts/fixtures/dau-by-mau.js.es6 +++ b/test/javascripts/fixtures/dau-by-mau.js.es6 @@ -13,7 +13,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": 46, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/inactive-users.js.es6 b/test/javascripts/fixtures/inactive-users.js.es6 deleted file mode 100644 index b94a48db57..0000000000 --- a/test/javascripts/fixtures/inactive-users.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -export default { - "/admin/reports/inactive_users": { - "report": { - "type": "inactive_users", - "title": "Inactive Users", - "xaxis": "Day", - "yaxis": "Number of new inactive users", - "description": "Number of users that haven’t logged on for the last 3 months", - "data": null, - "total": null, - "start_date": "2018-04-26", - "end_date": "2018-05-03", - "category_id": null, - "group_id": null, - "prev30Days": null, - "labels": null - } - } -}; diff --git a/test/javascripts/fixtures/new-contributors.js.es6 b/test/javascripts/fixtures/new-contributors.js.es6 index 2d23b14380..fc3d6f86c9 100644 --- a/test/javascripts/fixtures/new-contributors.js.es6 +++ b/test/javascripts/fixtures/new-contributors.js.es6 @@ -42,7 +42,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": null, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/posts.js.es6 b/test/javascripts/fixtures/posts.js.es6 new file mode 100644 index 0000000000..dea8904521 --- /dev/null +++ b/test/javascripts/fixtures/posts.js.es6 @@ -0,0 +1,19 @@ +export default { + "/admin/reports/posts": { + "report": { + "type": "topics", + "title": "Topics", + "xaxis": "Day", + "yaxis": "Number of new posts", + "data": null, + "total": null, + "start_date": "2018-03-26T00:00:00.000Z", + "end_date": "2018-04-25T23:59:59.999Z", + "category_id": null, + "group_id": null, + "prev30Days": 0, + "labels": null, + "report_key": "" + } + } +}; diff --git a/test/javascripts/fixtures/problems.js.es6 b/test/javascripts/fixtures/problems.js.es6 new file mode 100644 index 0000000000..d9f77979fd --- /dev/null +++ b/test/javascripts/fixtures/problems.js.es6 @@ -0,0 +1,4 @@ +export default { + "/admin/dashboard/problems.json": { + } +}; diff --git a/test/javascripts/fixtures/signups.js.es6 b/test/javascripts/fixtures/signups.js.es6 index a93d26fe06..dafe3909d0 100644 --- a/test/javascripts/fixtures/signups.js.es6 +++ b/test/javascripts/fixtures/signups.js.es6 @@ -39,7 +39,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": 0, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/top_referred_topics.js.es6 b/test/javascripts/fixtures/top_referred_topics.js.es6 new file mode 100644 index 0000000000..d601ff9d0e --- /dev/null +++ b/test/javascripts/fixtures/top_referred_topics.js.es6 @@ -0,0 +1,18 @@ +export default { + "/admin/reports/top_referred_topics": { + "report": { + "type": "top_referred_topics", + "title": "Trending search", + "xaxis": "", + "yaxis": "", + "data": null, + "total": null, + "start_date": "2018-03-26T00:00:00.000Z", + "end_date": "2018-04-25T23:59:59.999Z", + "category_id": null, + "group_id": null, + "prev30Days": null, + "labels": ["Topic", "Visits"] + } + } +}; diff --git a/test/javascripts/fixtures/topics.js.es6 b/test/javascripts/fixtures/topics.js.es6 index 86e888af4f..c5a5f9b245 100644 --- a/test/javascripts/fixtures/topics.js.es6 +++ b/test/javascripts/fixtures/topics.js.es6 @@ -12,7 +12,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": 0, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/trending-search.js.es6 b/test/javascripts/fixtures/trending-search.js.es6 index a8255e1ade..c02721d8ec 100644 --- a/test/javascripts/fixtures/trending-search.js.es6 +++ b/test/javascripts/fixtures/trending-search.js.es6 @@ -12,7 +12,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": null, - "labels": ["Term", "Searches", "Unique"] + "labels": ["Term", "Searches", "Unique"], + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/users-by-trust-level.js.es6 b/test/javascripts/fixtures/users-by-trust-level.js.es6 index 11d227e8b1..6af6b12c74 100644 --- a/test/javascripts/fixtures/users-by-trust-level.js.es6 +++ b/test/javascripts/fixtures/users-by-trust-level.js.es6 @@ -13,7 +13,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": null, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/fixtures/users-by-type.js.es6 b/test/javascripts/fixtures/users-by-type.js.es6 index e72d5cbf49..1da202a59e 100644 --- a/test/javascripts/fixtures/users-by-type.js.es6 +++ b/test/javascripts/fixtures/users-by-type.js.es6 @@ -13,7 +13,8 @@ export default { "category_id": null, "group_id": null, "prev30Days": null, - "labels": null + "labels": null, + "report_key": "" } } }; diff --git a/test/javascripts/lib/pretty-text-test.js.es6 b/test/javascripts/lib/pretty-text-test.js.es6 index a78e958d4a..892a9aca49 100644 --- a/test/javascripts/lib/pretty-text-test.js.es6 +++ b/test/javascripts/lib/pretty-text-test.js.es6 @@ -717,10 +717,6 @@ QUnit.test("quotes", assert => { "[quote=\"eviltrout, post:1, topic:2, full:true\"]\n**lorem** ipsum\n[/quote]\n\n", "keeps BBCode formatting"); - formatQuote("this is a bug", - "[quote=\"eviltrout, post:1, topic:2\"]\nthis is <not> a bug\n[/quote]\n\n", - "it escapes the contents of the quote"); - assert.cooked("[quote]\ntest\n[/quote]", "", "it supports quotes without params"); diff --git a/test/javascripts/lib/to-markdown-test.js.es6 b/test/javascripts/lib/to-markdown-test.js.es6 index 4a99ab8138..6336da48ad 100644 --- a/test/javascripts/lib/to-markdown-test.js.es6 +++ b/test/javascripts/lib/to-markdown-test.js.es6 @@ -104,7 +104,7 @@ QUnit.test("converts table tags", assert => { Loremipsum dolor sit amet - + `; @@ -181,6 +181,9 @@ QUnit.test("supporting html tags by keeping them", assert => { html = `Lorem ipsum dolor sit.`; assert.equal(toMarkdown(html), html); + html = `Have you tried clicking the Help Me! button?`; + assert.equal(toMarkdown(html), html); + html = `Lorem ipsum \n\n\n dolor sit.`; output = `Lorem [ipsum dolor sit.](http://example.com)`; assert.equal(toMarkdown(html), output); @@ -286,3 +289,16 @@ QUnit.test("converts list tag from word", assert => { const markdown = `Sample\n\n* **Item 1**\n * *Item 2*\n * Item 3\n* Item 4\n\nList`; assert.equal(toMarkdown(html), markdown); }); + +QUnit.test("keeps mention/hash class", assert => { + const html = ` +

    User mention: @discourse

    +

    Group mention: @discourse-group

    +

    Category link: #foo

    +

    Sub-category link: #foo:bar

    + `; + + const markdown = `User mention: @discourse\n\nGroup mention: @discourse-group\n\nCategory link: #foo\n\nSub-category link: #foo:bar`; + + assert.equal(toMarkdown(html), markdown); +}); diff --git a/test/javascripts/models/report-test.js.es6 b/test/javascripts/models/report-test.js.es6 index 1e4f73d89e..e9661eadb6 100644 --- a/test/javascripts/models/report-test.js.es6 +++ b/test/javascripts/models/report-test.js.es6 @@ -60,4 +60,4 @@ QUnit.test("thirtyDayCountTitle", assert => { assert.ok(title.indexOf('+50%') !== -1); assert.ok(title.match(/Was 10/)); -}); \ No newline at end of file +});