diff --git a/.rubocop.yml b/.rubocop.yml index e28a0876aa..9d2e219e3c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -114,3 +114,10 @@ Layout/AlignHash: Bundler/OrderedGems: Enabled: false + +Style/SingleLineMethods: + Enabled: true + +Style/Semicolon: + Enabled: true + AllowAsExpressionSeparator: true diff --git a/Gemfile b/Gemfile index f91aeca1e5..cb127b5ca1 100644 --- a/Gemfile +++ b/Gemfile @@ -13,13 +13,15 @@ if rails_master? gem 'rails', git: 'https://github.com/rails/rails.git' gem 'seed-fu', git: 'https://github.com/SamSaffron/seed-fu.git', branch: 'discourse' else - gem 'actionmailer', '5.2' - gem 'actionpack', '5.2' - gem 'actionview', '5.2' - gem 'activemodel', '5.2' - gem 'activerecord', '5.2' - gem 'activesupport', '5.2' - gem 'railties', '5.2' + # until rubygems gives us optional dependencies we are stuck with this + # bundle update actionmailer actionpack actionview activemodel activerecord activesupport railties + gem 'actionmailer', '5.2.2' + gem 'actionpack', '5.2.2' + gem 'actionview', '5.2.2' + gem 'activemodel', '5.2.2' + gem 'activerecord', '5.2.2' + gem 'activesupport', '5.2.2' + gem 'railties', '5.2.2' gem 'sprockets-rails' gem 'seed-fu' end @@ -34,7 +36,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.68' +gem 'onebox', '1.8.69' gem 'http_accept_language', '~>2.0.5', require: false @@ -43,7 +45,12 @@ gem 'ember-source', '2.13.3' gem 'ember-handlebars-template', '0.7.5' gem 'barber' -gem 'message_bus' +# message bus 2.2.0 should be very stable +# we trimmed some of the internal API surface down so we went with +# a pre release here to make we don't do a full release prior to +# baking here. Remove 2.2.0.pre no later than Jan 2019 and move back +# to the standard releases +gem 'message_bus', '2.2.0.pre.1' gem 'rails_multisite' diff --git a/Gemfile.lock b/Gemfile.lock index 22551fd1a3..859d13a075 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,37 +1,37 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (5.2.0) - actionpack (= 5.2.0) - actionview (= 5.2.0) - activejob (= 5.2.0) + actionmailer (5.2.2) + actionpack (= 5.2.2) + actionview (= 5.2.2) + activejob (= 5.2.2) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.0) - actionview (= 5.2.0) - activesupport (= 5.2.0) + actionpack (5.2.2) + actionview (= 5.2.2) + activesupport (= 5.2.2) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.0) - activesupport (= 5.2.0) + actionview (5.2.2) + activesupport (= 5.2.2) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) active_model_serializers (0.8.4) activemodel (>= 3.0) - activejob (5.2.0) - activesupport (= 5.2.0) + activejob (5.2.2) + activesupport (= 5.2.2) globalid (>= 0.3.6) - activemodel (5.2.0) - activesupport (= 5.2.0) - activerecord (5.2.0) - activemodel (= 5.2.0) - activesupport (= 5.2.0) + activemodel (5.2.2) + activesupport (= 5.2.2) + activerecord (5.2.2) + activemodel (= 5.2.2) + activesupport (= 5.2.2) arel (>= 9.0) - activesupport (5.2.0) + activesupport (5.2.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -83,7 +83,7 @@ GEM open4 (~> 1.3) coderay (1.1.2) colored2 (3.1.2) - concurrent-ruby (1.0.5) + concurrent-ruby (1.1.3) connection_pool (2.2.2) cork (0.3.0) colored2 (~> 3.1) @@ -158,7 +158,7 @@ GEM hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.0.5) - i18n (1.0.1) + i18n (1.1.1) concurrent-ruby (~> 1.0) image_size (1.5.0) in_threads (1.5.0) @@ -193,11 +193,11 @@ GEM mini_mime (>= 0.1.1) maxminddb (0.1.21) memory_profiler (0.9.12) - message_bus (2.1.6) + message_bus (2.2.0.pre.1) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) - mini_mime (1.0.0) + mini_mime (1.0.1) mini_portile2 (2.3.0) mini_racer (0.2.3) libv8 (>= 6.3) @@ -258,7 +258,7 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.68) + onebox (1.8.69) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -270,7 +270,7 @@ GEM redis ruby-openid parallel (1.12.1) - parser (2.5.1.0) + parser (2.5.3.0) ast (~> 2.4.0) pg (1.1.3) powerpack (0.1.2) @@ -287,14 +287,14 @@ GEM puma (3.11.4) r2 (0.2.7) rack (2.0.6) - rack-mini-profiler (1.0.0) + rack-mini-profiler (1.0.1) rack (>= 1.2.0) rack-openid (1.3.1) rack (>= 1.1.0) ruby-openid (>= 2.1.8) rack-protection (2.0.3) rack - rack-test (1.0.0) + rack-test (1.1.0) rack (>= 1.0, < 3) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) @@ -304,15 +304,15 @@ GEM rails_multisite (2.0.4) activerecord (> 4.2, < 6) railties (> 4.2, < 6) - railties (5.2.0) - actionpack (= 5.2.0) - activesupport (= 5.2.0) + railties (5.2.2) + actionpack (= 5.2.2) + activesupport (= 5.2.2) method_source rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) + thor (>= 0.19.0, < 2.0) rainbow (3.0.0) raindrops (0.19.0) - rake (12.3.1) + rake (12.3.2) rake-compiler (1.0.4) rake rb-fsevent (0.10.3) @@ -357,17 +357,17 @@ GEM rspec-support (~> 3.7.0) rspec-support (3.7.1) rtlit (0.0.5) - rubocop (0.57.2) + rubocop (0.60.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5) + parser (>= 2.5, != 2.5.1.1) powerpack (~> 0.1) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) + unicode-display_width (~> 1.4.0) ruby-openid (2.7.0) ruby-prof (0.17.0) - ruby-progressbar (1.9.0) + ruby-progressbar (1.10.0) ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) @@ -443,13 +443,13 @@ PLATFORMS ruby DEPENDENCIES - actionmailer (= 5.2) - actionpack (= 5.2) - actionview (= 5.2) + actionmailer (= 5.2.2) + actionpack (= 5.2.2) + actionview (= 5.2.2) active_model_serializers (~> 0.8.3) - activemodel (= 5.2) - activerecord (= 5.2) - activesupport (= 5.2) + activemodel (= 5.2.2) + activerecord (= 5.2.2) + activesupport (= 5.2.2) annotate aws-sdk-s3 barber @@ -491,7 +491,7 @@ DEPENDENCIES mail (= 2.7.1.rc1) maxminddb memory_profiler - message_bus + message_bus (= 2.2.0.pre.1) mini_mime mini_racer mini_scheduler @@ -512,7 +512,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.68) + onebox (= 1.8.69) openid-redis-store pg pry-nav @@ -522,7 +522,7 @@ DEPENDENCIES rack-mini-profiler rack-protection rails_multisite - railties (= 5.2) + railties (= 5.2.2) rake rb-fsevent rb-inotify (~> 0.9) diff --git a/app/assets/images/push-notifications/check.png b/app/assets/images/push-notifications/check.png index ad223d26f3..b613330693 100644 Binary files a/app/assets/images/push-notifications/check.png and b/app/assets/images/push-notifications/check.png differ diff --git a/app/assets/images/push-notifications/group_mentioned.png b/app/assets/images/push-notifications/group_mentioned.png index ebb5560414..c3edbcc20b 100644 Binary files a/app/assets/images/push-notifications/group_mentioned.png 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 index 5e25f2426a..f4770f17c9 100644 Binary files a/app/assets/images/push-notifications/linked.png 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 index ebb5560414..c3edbcc20b 100644 Binary files a/app/assets/images/push-notifications/mentioned.png 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 index 41d02aff0e..8835fcbe04 100644 Binary files a/app/assets/images/push-notifications/posted.png 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 index 8e71e69c7f..2f1d97b204 100644 Binary files a/app/assets/images/push-notifications/private_message.png 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 index 01d889b468..16d44dd1f3 100644 Binary files a/app/assets/images/push-notifications/quoted.png 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 index 41d02aff0e..8835fcbe04 100644 Binary files a/app/assets/images/push-notifications/replied.png and b/app/assets/images/push-notifications/replied.png differ diff --git a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 index 8994c9d9b4..d103e6f912 100644 --- a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 @@ -6,58 +6,79 @@ export default Ember.Component.extend({ limit: 8, total: 0, + init() { + this._super(...arguments); + + this.resizeHandler = () => + Ember.run.debounce(this, this._scheduleChartRendering, 500); + }, + + didInsertElement() { + this._super(...arguments); + + $(window).on("resize.chart", this.resizeHandler); + }, + willDestroyElement() { this._super(...arguments); + $(window).off("resize.chart", this.resizeHandler); + this._resetChart(); }, didReceiveAttrs() { this._super(...arguments); + Ember.run.debounce(this, this._scheduleChartRendering, 100); + }, + + _scheduleChartRendering() { Ember.run.schedule("afterRender", () => { - const $chartCanvas = this.$(".chart-canvas"); - if (!$chartCanvas || !$chartCanvas.length) return; + this._renderChart(this.get("model"), this.$(".chart-canvas")); + }); + }, - const context = $chartCanvas[0].getContext("2d"); - const model = this.get("model"); - const chartData = Ember.makeArray( - model.get("chartData") || model.get("data") - ); - const prevChartData = Ember.makeArray( - model.get("prevChartData") || model.get("prev_data") - ); + _renderChart(model, $chartCanvas) { + if (!$chartCanvas || !$chartCanvas.length) return; - const labels = chartData.map(d => d.x); + const context = $chartCanvas[0].getContext("2d"); + const chartData = Ember.makeArray( + model.get("chartData") || model.get("data") + ); + const prevChartData = Ember.makeArray( + model.get("prevChartData") || model.get("prev_data") + ); - const data = { - labels, - datasets: [ - { - data: chartData.map(d => Math.round(parseFloat(d.y))), - backgroundColor: prevChartData.length - ? "transparent" - : model.secondary_color, - borderColor: model.primary_color - } - ] - }; + const labels = chartData.map(d => d.x); - if (prevChartData.length) { - data.datasets.push({ - data: prevChartData.map(d => Math.round(parseFloat(d.y))), - borderColor: model.primary_color, - borderDash: [5, 5], - backgroundColor: "transparent", - borderWidth: 1, - pointRadius: 0 - }); - } + const data = { + labels, + datasets: [ + { + data: chartData.map(d => Math.round(parseFloat(d.y))), + backgroundColor: prevChartData.length + ? "transparent" + : model.secondary_color, + borderColor: model.primary_color + } + ] + }; - loadScript("/javascripts/Chart.min.js").then(() => { - this._resetChart(); - this._chart = new window.Chart(context, this._buildChartConfig(data)); + if (prevChartData.length) { + data.datasets.push({ + data: prevChartData.map(d => Math.round(parseFloat(d.y))), + borderColor: model.primary_color, + borderDash: [5, 5], + backgroundColor: "transparent", + borderWidth: 1, + pointRadius: 0 }); + } + + loadScript("/javascripts/Chart.min.js").then(() => { + this._resetChart(); + this._chart = new window.Chart(context, this._buildChartConfig(data)); }); }, diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 index c8082cc84d..a8acc0e2f0 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -154,7 +154,7 @@ export default Ember.Controller.extend({ @computed("maximized") maximizeIcon(maximized) { - return maximized ? "compress" : "expand"; + return maximized ? "discourse-compress" : "discourse-expand"; }, @computed("model.isSaving") diff --git a/app/assets/javascripts/admin/controllers/admin-email-advanced-test.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-advanced-test.js.es6 new file mode 100644 index 0000000000..34b2d7b049 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-email-advanced-test.js.es6 @@ -0,0 +1,30 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Controller.extend({ + email: null, + text: null, + elided: null, + format: null, + loading: null, + + actions: { + run() { + this.set("loading", true); + + ajax("/admin/email/advanced-test", { + type: "POST", + data: { email: this.get("email") } + }) + .then(data => { + this.setProperties({ + text: data.text, + elided: data.elided, + format: data.format + }); + }) + .catch(popupAjaxError) + .finally(() => this.set("loading", false)); + } + } +}); 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 c2d96201bd..a5831299ec 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 @@ -4,7 +4,7 @@ export default Ember.Controller.extend({ categoryNameKey: null, adminSiteSettings: Ember.inject.controller(), - @computed("adminSiteSettings.model", "categoryNameKey") + @computed("adminSiteSettings.visibleSiteSettings", "categoryNameKey") category(categories, nameKey) { return (categories || []).findBy("nameKey", nameKey); }, 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 5d34c26293..25420b562d 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -2,6 +2,8 @@ import debounce from "discourse/lib/debounce"; export default Ember.Controller.extend({ filter: null, + allSiteSettings: Ember.computed.alias("model"), + visibleSiteSettings: null, onlyOverridden: false, filterContentNow(category) { @@ -14,7 +16,7 @@ export default Ember.Controller.extend({ } if ((!filter || 0 === filter.length) && !this.get("onlyOverridden")) { - this.set("model", this.get("allSiteSettings")); + this.set("visibleSiteSettings", this.get("allSiteSettings")); this.transitionToRoute("adminSiteSettings"); return; } @@ -62,7 +64,7 @@ export default Ember.Controller.extend({ all.hasMore = matches.length > 30; all.count = all.hasMore ? "30+" : matches.length; - this.set("model", matchesGroupedByCategory); + this.set("visibleSiteSettings", matchesGroupedByCategory); this.transitionToRoute( "adminSiteSettingsCategory", category || "all_results" diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 index 279fc53536..55e6b007fd 100644 --- a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 @@ -9,6 +9,11 @@ export default Ember.Controller.extend({ defaultEventTypes: Ember.computed.alias("adminWebHooks.defaultEventTypes"), contentTypes: Ember.computed.alias("adminWebHooks.contentTypes"), + @computed + showTagsFilter() { + return this.siteSettings.tagging_enabled; + }, + @computed("model.isSaving", "saved", "saveButtonDisabled") savingStatus(isSaving, saved, saveButtonDisabled) { if (isSaving) { diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index fc36b9bfe8..d98e148a5a 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -98,6 +98,7 @@ const AdminUser = Discourse.User.extend({ }, deleteAllPosts() { + let deletedPosts = 0; const user = this, message = I18n.messageFormat("admin.user.delete_all_posts_confirm_MF", { POSTS: user.get("post_count"), @@ -114,13 +115,52 @@ const AdminUser = Discourse.User.extend({ `${iconHTML("exclamation-triangle")} ` + I18n.t("admin.user.delete_all_posts"), class: "btn btn-danger", - callback: function() { - ajax("/admin/users/" + user.get("id") + "/delete_all_posts", { - type: "PUT" - }).then(() => user.set("post_count", 0)); + callback: () => { + openProgressModal(); + performDelete(); } } - ]; + ], + openProgressModal = () => { + bootbox.dialog( + `

${I18n.t( + "admin.user.delete_posts_progress" + )}

`, + [], + { classes: "delete-posts-progress" } + ); + }, + performDelete = () => { + let deletedPercentage = 0; + return ajax(`/admin/users/${user.get("id")}/delete_posts_batch`, { + type: "PUT" + }) + .then(({ posts_deleted }) => { + if (posts_deleted === 0) { + user.set("post_count", 0); + bootbox.hideAll(); + } else { + deletedPosts += posts_deleted; + deletedPercentage = Math.floor( + (deletedPosts * 100) / user.get("post_count") + ); + $(".delete-posts-progress .progress-bar > span").css({ + width: `${deletedPercentage}%` + }); + performDelete(); + } + }) + .catch(e => { + bootbox.hideAll(); + let error; + AdminUser.find(user.get("id")).then(u => user.setProperties(u)); + if (e.responseJSON && e.responseJSON.errors) { + error = e.responseJSON.errors[0]; + } + error = error || I18n.t("admin.user.delete_posts_failed"); + bootbox.alert(error); + }); + }; bootbox.dialog(message, buttons, { classes: "delete-all-posts" }); }, diff --git a/app/assets/javascripts/admin/models/flagged-post.js.es6 b/app/assets/javascripts/admin/models/flagged-post.js.es6 index d2a9c4510f..fde2cdf01a 100644 --- a/app/assets/javascripts/admin/models/flagged-post.js.es6 +++ b/app/assets/javascripts/admin/models/flagged-post.js.es6 @@ -136,7 +136,7 @@ export default Post.extend({ label: I18n.t("yes_value"), class: "btn-danger", callback() { - Post.deleteMany(replies.map(r => r.id)) + Post.deleteMany(replies.map(r => r.id), { deferFlags: true }) .then(action) .then(resolve) .catch(error => { diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 709dac0a52..6fc812f349 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -276,9 +276,13 @@ const Report = Discourse.Model.extend({ return this._numberLabel(value, opts); } if (type === "date") { - const date = moment(value, "YYYY-MM-DD"); + const date = moment(value); if (date.isValid()) return this._dateLabel(value, date); } + if (type === "precise_date") { + const date = moment(value); + if (date.isValid()) return this._dateLabel(value, date, "LLL"); + } if (type === "text") return this._textLabel(value); return { @@ -377,10 +381,10 @@ const Report = Discourse.Model.extend({ }; }, - _dateLabel(value, date) { + _dateLabel(value, date, format = "LL") { return { value, - formatedValue: value ? date.format("LL") : "—" + formatedValue: value ? date.format(format) : "—" }; }, diff --git a/app/assets/javascripts/admin/models/web-hook.js.es6 b/app/assets/javascripts/admin/models/web-hook.js.es6 index 142d22b797..a2a7ae79fb 100644 --- a/app/assets/javascripts/admin/models/web-hook.js.es6 +++ b/app/assets/javascripts/admin/models/web-hook.js.es6 @@ -63,6 +63,7 @@ export default RestModel.extend({ createProperties() { const types = this.get("web_hook_event_types"); const categoryIds = this.get("categories").map(c => c.id); + const tagNames = this.get("tag_names"); // Hack as {{group-selector}} accepts a comma-separated string as data source, but // we use an array to populate the datasource above. @@ -81,6 +82,7 @@ export default RestModel.extend({ ? [null] : types.map(type => type.id), category_ids: Ember.isEmpty(categoryIds) ? [null] : categoryIds, + tag_names: Ember.isEmpty(tagNames) ? [null] : tagNames, group_ids: Ember.isEmpty(groupNames) || Ember.isEmpty(groupNames[0]) ? [null] 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 110ec6231b..4ce0b8f313 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -8,6 +8,10 @@ export default function() { path: "/dashboard/moderation", resetNamespace: true }); + this.route("admin.dashboardNextSecurity", { + path: "/dashboard/security", + resetNamespace: true + }); }); this.route( @@ -31,6 +35,7 @@ export default function() { this.route("received"); this.route("rejected"); this.route("previewDigest", { path: "/preview-digest" }); + this.route("advancedTest", { path: "/advanced-test" }); } ); diff --git a/app/assets/javascripts/admin/routes/admin-site-settings-index.js.es6 b/app/assets/javascripts/admin/routes/admin-site-settings-index.js.es6 index 451fe71d2f..59e851e509 100644 --- a/app/assets/javascripts/admin/routes/admin-site-settings-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-site-settings-index.js.es6 @@ -6,7 +6,8 @@ export default Discourse.Route.extend({ beforeModel() { this.replaceWith( "adminSiteSettingsCategory", - this.modelFor("adminSiteSettings")[0].nameKey + this.controllerFor("adminSiteSettings").get("visibleSiteSettings")[0] + .nameKey ); } }); diff --git a/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 b/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 index 48418d3132..58ac7246f5 100644 --- a/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-site-settings.js.es6 @@ -10,9 +10,10 @@ export default Discourse.Route.extend({ }, afterModel(siteSettings) { - this.controllerFor("adminSiteSettings").set( - "allSiteSettings", - siteSettings - ); + const controller = this.controllerFor("adminSiteSettings"); + + if (!controller.get("visibleSiteSettings")) { + controller.set("visibleSiteSettings", siteSettings); + } } }); diff --git a/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 index f548a4e202..611b015dcb 100644 --- a/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-web-hooks-show.js.es6 @@ -19,6 +19,7 @@ export default Discourse.Route.extend({ } model.set("category_ids", model.get("category_ids")); + model.set("tag_names", model.get("tag_names")); model.set("group_ids", model.get("group_ids")); controller.setProperties({ model, saved: false }); }, diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index 176f90c22d..e7b1e1c576 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -21,6 +21,11 @@ {{i18n "admin.dashboard.moderation_tab"}} {{/link-to}} + {{outlet}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next_security.hbs b/app/assets/javascripts/admin/templates/dashboard_next_security.hbs new file mode 100644 index 0000000000..a42e65d58a --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard_next_security.hbs @@ -0,0 +1,15 @@ +
+ {{plugin-outlet name="admin-dashboard-security-top"}} + +
+ {{admin-report + dataSourceName="suspicious_logins" + filters=lastWeekfilters}} + + {{admin-report + dataSourceName="staff_logins" + filters=lastWeekfilters}} + + {{plugin-outlet name="admin-dashboard-security-bottom"}} +
+
diff --git a/app/assets/javascripts/admin/templates/email-advanced-test.hbs b/app/assets/javascripts/admin/templates/email-advanced-test.hbs new file mode 100644 index 0000000000..c0b8ee7a27 --- /dev/null +++ b/app/assets/javascripts/admin/templates/email-advanced-test.hbs @@ -0,0 +1,25 @@ +

{{i18n 'admin.email.advanced_test.desc'}}

+ +
+ + {{textarea name="email" value=email class="email-body"}} + +
+ +{{#conditional-loading-spinner condition=loading}} + +{{#if format}} +
+
+

{{i18n 'admin.email.advanced_test.text'}}

+
{{{text}}}
+
+ +
+
+

{{i18n 'admin.email.advanced_test.elided'}}

+
{{{elided}}}
+
+{{/if}} + +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/email.hbs b/app/assets/javascripts/admin/templates/email.hbs index 509d5d2081..b8a379d8ae 100644 --- a/app/assets/javascripts/admin/templates/email.hbs +++ b/app/assets/javascripts/admin/templates/email.hbs @@ -1,6 +1,7 @@ {{#admin-nav}} {{nav-item route='adminEmail.index' label='admin.email.settings'}} {{nav-item route='adminEmail.previewDigest' label='admin.email.preview_digest'}} + {{nav-item route='adminEmail.advancedTest' label='admin.email.advanced_test.title'}} {{nav-item route='adminCustomizeEmailTemplates' label='admin.email.templates'}} {{nav-item route='adminEmail.sent' label='admin.email.sent'}} {{nav-item route='adminEmail.skipped' label='admin.email.skipped'}} diff --git a/app/assets/javascripts/admin/templates/search-logs-index.hbs b/app/assets/javascripts/admin/templates/search-logs-index.hbs index 507983747c..a6bba95af5 100644 --- a/app/assets/javascripts/admin/templates/search-logs-index.hbs +++ b/app/assets/javascripts/admin/templates/search-logs-index.hbs @@ -21,7 +21,7 @@
{{i18n 'admin.logs.search_logs.searches'}}
{{item.searches}}
{{i18n 'admin.logs.search_logs.click_through'}}
{{item.click_through}} -
{{i18n 'admin.logs.search_logs.unique_searches'}}
{{item.unique_searches}} +
{{i18n 'admin.logs.search_logs.unique'}}
{{item.unique_searches}} {{/each}} diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs index 1d1e579d7a..4cdf577962 100644 --- a/app/assets/javascripts/admin/templates/site-settings.hbs +++ b/app/assets/javascripts/admin/templates/site-settings.hbs @@ -15,7 +15,7 @@
+ {{#if showTagsFilter}} +
+ + {{tag-chooser tags=model.tag_names everyTag=true}} +
{{i18n 'admin.web_hooks.tags_filter_instructions'}}
+
+ {{/if}}
{{group-selector groupNames=model.groupsFilterInName groupFinder=model.groupFinder}} diff --git a/app/assets/javascripts/discourse-common/lib/deprecated.js.es6 b/app/assets/javascripts/discourse-common/lib/deprecated.js.es6 index ecb2f152d5..e1d82d3bb2 100644 --- a/app/assets/javascripts/discourse-common/lib/deprecated.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/deprecated.js.es6 @@ -1,3 +1,15 @@ -export default function deprecated(msg) { - console.warn(`DEPRECATION: ${msg}`); // eslint-disable-line no-console +export default function deprecated(msg, opts = {}) { + msg = ["Deprecation notice:", msg]; + if (opts.since) { + msg.push(`(deprecated since Discourse ${opts.since})`); + } + if (opts.dropFrom) { + msg.push(`(removal in Discourse ${opts.dropFrom})`); + } + msg = msg.join(" "); + + if (opts.raiseError) { + throw msg; + } + console.warn(msg); // eslint-disable-line no-console } diff --git a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 index d59e03419b..1fe28e89ee 100644 --- a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 @@ -470,6 +470,7 @@ const fa4Replacements = { "vimeo-square": "fab-vimeo-square", vine: "fab-vine", vk: "fab-vk", + vkontakte: "fab-vk", "volume-control-phone": "phone-volume", warning: "exclamation-triangle", wechat: "fab-weixin", diff --git a/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 b/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 index 0555b7fc4b..bf558ed48e 100644 --- a/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 +++ b/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 @@ -12,8 +12,5 @@ export default Ember.Component.extend({ 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")) - ); } }); diff --git a/app/assets/javascripts/discourse/components/composer-toggles.js.es6 b/app/assets/javascripts/discourse/components/composer-toggles.js.es6 index e1172df2e2..f1fe514f4f 100644 --- a/app/assets/javascripts/discourse/components/composer-toggles.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-toggles.js.es6 @@ -12,9 +12,11 @@ export default Ember.Component.extend({ @computed("composeState") fullscreenTitle(composeState) { - return composeState === "fullscreen" - ? "composer.exit_fullscreen" - : "composer.enter_fullscreen"; + return composeState === "draft" + ? "composer.open" + : composeState === "fullscreen" + ? "composer.exit_fullscreen" + : "composer.enter_fullscreen"; }, @computed("composeState") @@ -26,6 +28,10 @@ export default Ember.Component.extend({ @computed("composeState") fullscreenIcon(composeState) { - return composeState === "fullscreen" ? "compress" : "expand"; + return composeState === "draft" + ? "chevron-up" + : composeState === "fullscreen" + ? "discourse-compress" + : "discourse-expand"; } }); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 1293d032d4..cddb33cbdf 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -410,10 +410,17 @@ export default Ember.Component.extend({ }, onKeyUp(text, cp) { - const matches = /(?:^|[^a-z])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( - text.substring(0, cp) - ); + // Regular expressions used to extract emoji name from text. + // The space version requires a ' ' (space) before the emoji name + // (i.e. ' :smile'), while the other one does not and is used + // when enable_inline_emoji_translation is true. + const noSpaceColonEmoji = /(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi; + const spaceColonEmoji = /(?:^|[^a-z])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi; + const regex = self.siteSettings.enable_inline_emoji_translation + ? noSpaceColonEmoji + : spaceColonEmoji; + const matches = regex.exec(text.substring(0, cp)); if (matches && matches[1]) { return [matches[1]]; } @@ -676,8 +683,13 @@ export default Ember.Component.extend({ // Replace value (side effect: cursor at the end). this.set("value", val.replace(oldVal, newVal)); - // Restore cursor. - this._selectText(newSelection.start, newSelection.end - newSelection.start); + if ($("textarea.d-editor-input").is(":focus")) { + // Restore cursor. + this._selectText( + newSelection.start, + newSelection.end - newSelection.start + ); + } }, _addBlock(sel, text) { diff --git a/app/assets/javascripts/discourse/components/plugin-connector.js.es6 b/app/assets/javascripts/discourse/components/plugin-connector.js.es6 index bd1eabac9c..4d0ece9c6f 100644 --- a/app/assets/javascripts/discourse/components/plugin-connector.js.es6 +++ b/app/assets/javascripts/discourse/components/plugin-connector.js.es6 @@ -12,6 +12,8 @@ export default Ember.Component.extend({ const connectorClass = this.get("connector.connectorClass"); connectorClass.setupComponent.call(this, args, this); + + this.set("actions", connectorClass.actions); }, @observes("args") diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 970f3a83bf..ab765a065c 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -1,6 +1,11 @@ import MountWidget from "discourse/components/mount-widget"; import { observes } from "ember-addons/ember-computed-decorators"; import Docking from "discourse/mixins/docking"; +import PanEvents, { + SWIPE_VELOCITY, + SWIPE_DISTANCE_THRESHOLD, + SWIPE_VELOCITY_THRESHOLD +} from "discourse/mixins/pan-events"; const _flagProperties = []; function addFlagProperty(prop) { @@ -9,10 +14,20 @@ function addFlagProperty(prop) { const PANEL_BODY_MARGIN = 30; -const SiteHeaderComponent = MountWidget.extend(Docking, { +//android supports pulling in from the screen edges +const SCREEN_EDGE_MARGIN = 30; +const SCREEN_OFFSET = 300; + +const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { widget: "header", docAt: null, dockedHeader: null, + _animate: false, + _isPanning: false, + _panMenuOrigin: "right", + _panMenuOffset: 0, + _scheduledMovingAnimation: null, + _scheduledRemoveAnimate: null, _topic: null, @observes( @@ -23,6 +38,157 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.queueRerender(); }, + _animateOpening($panel) { + $panel.css({ right: "", left: "" }); + this._panMenuOffset = 0; + }, + + _animateClosing($panel, menuOrigin, windowWidth) { + $panel.css(menuOrigin, -windowWidth); + this._animate = true; + Ember.run.schedule("afterRender", () => { + this.eventDispatched("dom:clean", "header"); + this._panMenuOffset = 0; + }); + }, + + _isRTL() { + return $("html").css("direction") === "rtl"; + }, + + _leftMenuClass() { + return this._isRTL() ? ".user-menu" : ".hamburger-panel"; + }, + + _leftMenuAction() { + return this._isRTL() ? "toggleUserMenu" : "toggleHamburger"; + }, + + _rightMenuAction() { + return this._isRTL() ? "toggleHamburger" : "toggleUserMenu"; + }, + + _handlePanDone(offset, event) { + const $window = $(window); + const windowWidth = parseInt($window.width()); + const $menuPanels = $(".menu-panel"); + const menuOrigin = this._panMenuOrigin; + this._shouldMenuClose(event, menuOrigin) + ? (offset += SWIPE_VELOCITY) + : (offset -= SWIPE_VELOCITY); + $menuPanels.each((idx, panel) => { + const $panel = $(panel); + const $headerCloak = $(".header-cloak"); + $panel.css(menuOrigin, -offset); + $headerCloak.css("opacity", Math.min(0.5, (300 - offset) / 600)); + if (offset > windowWidth) { + this._animateClosing($panel, menuOrigin, windowWidth); + } else if (offset <= 0) { + this._animateOpening($panel); + } else { + //continue to open or close menu + this._scheduledMovingAnimation = window.requestAnimationFrame(() => + this._handlePanDone(offset, event) + ); + } + }); + }, + + _shouldMenuClose(e, menuOrigin) { + // menu should close after a pan either: + // if a user moved the panel closed past a threshold and away and is NOT swiping back open + // if a user swiped to close fast enough regardless of distance + if (menuOrigin === "right") { + return ( + (e.deltaX > SWIPE_DISTANCE_THRESHOLD && + e.velocityX > -SWIPE_VELOCITY_THRESHOLD) || + e.velocityX > 0 + ); + } else { + return ( + (e.deltaX < -SWIPE_DISTANCE_THRESHOLD && + e.velocityX < SWIPE_VELOCITY_THRESHOLD) || + e.velocityX < 0 + ); + } + }, + + panStart(e) { + const center = e.center; + const $centeredElement = $(document.elementFromPoint(center.x, center.y)); + const $window = $(window); + const windowWidth = parseInt($window.width()); + if ( + ($centeredElement.hasClass("panel-body") || + $centeredElement.hasClass("header-cloak") || + $centeredElement.parents(".panel-body").length) && + (e.direction === "left" || e.direction === "right") + ) { + e.originalEvent.preventDefault(); + this._isPanning = true; + } else if ( + center.x < SCREEN_EDGE_MARGIN && + !this.$(".menu-panel").length && + e.direction === "right" + ) { + this._animate = false; + this._panMenuOrigin = "left"; + this._panMenuOffset = -SCREEN_OFFSET; + this._isPanning = true; + $("header.d-header").removeClass("scroll-down scroll-up"); + this.eventDispatched(this._leftMenuAction(), "header"); + } else if ( + windowWidth - center.x < SCREEN_EDGE_MARGIN && + !this.$(".menu-panel").length && + e.direction === "left" + ) { + this._animate = false; + this._panMenuOrigin = "right"; + this._panMenuOffset = -SCREEN_OFFSET; + this._isPanning = true; + $("header.d-header").removeClass("scroll-down scroll-up"); + this.eventDispatched(this._rightMenuAction(), "header"); + } else { + this._isPanning = false; + } + }, + + panEnd(e) { + if (!this._isPanning) { + return; + } + this._isPanning = false; + $(".menu-panel").each((idx, panel) => { + const $panel = $(panel); + let offset = $panel.css("right"); + if (this._panMenuOrigin === "left") { + offset = $panel.css("left"); + } + offset = Math.abs(parseInt(offset, 10)); + this._handlePanDone(offset, e); + }); + }, + + panMove(e) { + if (!this._isPanning) { + return; + } + const $menuPanels = $(".menu-panel"); + $menuPanels.each((idx, panel) => { + const $panel = $(panel); + const $headerCloak = $(".header-cloak"); + if (this._panMenuOrigin === "right") { + const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset); + $panel.css("right", pxClosed); + $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); + } else { + const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset); + $panel.css("left", pxClosed); + $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); + } + }); + }, + dockCheck(info) { const $header = $("header.d-header"); @@ -47,6 +213,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { }, setTopic(topic) { + this.eventDispatched("dom:clean", "header"); this._topic = topic; this.queueRerender(); }, @@ -74,6 +241,14 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.eventDispatched("dom:clean", "header"); } }); + + if (this.site.mobileView) { + $("body") + .on("pointerdown", e => this._panStart(e)) + .on("pointermove", e => this._panMove(e)) + .on("pointerup", e => this._panMove(e)) + .on("pointercancel", e => this._panMove(e)); + } }, willDestroyElement() { @@ -84,6 +259,16 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.appEvents.off("header:show-topic"); this.appEvents.off("header:hide-topic"); this.appEvents.off("dom:clean"); + + if (this.site.mobileView) { + $("body") + .off("pointerdown") + .off("pointerup") + .off("pointermove") + .off("pointercancel"); + } + Ember.run.cancel(this._scheduledRemoveAnimate); + window.cancelAnimationFrame(this._scheduledMovingAnimation); }, buildArgs() { @@ -100,6 +285,9 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { afterRender() { const $menuPanels = $(".menu-panel"); if ($menuPanels.length === 0) { + if (this.site.mobileView) { + this._animate = true; + } return; } @@ -112,15 +300,29 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { $menuPanels.each((idx, panel) => { const $panel = $(panel); + const $headerCloak = $(".header-cloak"); let width = parseInt($panel.attr("data-max-width") || 300); if (windowWidth - width < 50) { width = windowWidth - 50; } + if (this._panMenuOffset) { + this._panMenuOffset = -width; + } - $panel - .removeClass("drop-down") - .removeClass("slide-in") - .addClass(viewMode); + $panel.removeClass("drop-down slide-in").addClass(viewMode); + if (this._animate || this._panMenuOffset !== 0) { + $headerCloak.css("opacity", 0); + if ( + this.site.mobileView && + $panel.parent(this._leftMenuClass()).length > 0 + ) { + this._panMenuOrigin = "left"; + $panel.css("left", -windowWidth); + } else { + this._panMenuOrigin = "right"; + $panel.css("right", -windowWidth); + } + } const $panelBody = $(".panel-body", $panel); // 2 pixel fudge allows for firefox subpixel sizing stuff causing scrollbar @@ -150,7 +352,8 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { if ( contentHeight + (offsetTop - scrollTop) + PANEL_BODY_MARGIN > - fullHeight + fullHeight || + this.site.mobileView ) { contentHeight = fullHeight - (offsetTop - scrollTop) - PANEL_BODY_MARGIN; @@ -160,11 +363,19 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { } $("body").addClass("drop-down-mode"); } else { - const menuTop = headerHeight(); + if (this.site.mobileView) { + $headerCloak.show(); + } + + const menuTop = this.site.mobileView ? 0 : headerHeight(); let height; - const winHeight = $(window).height() - 16; - if (menuTop + contentHeight < winHeight) { + const winHeightOffset = 16; + let initialWinHeight = window.innerHeight + ? window.innerHeight + : $(window).height(); + const winHeight = initialWinHeight - winHeightOffset; + if (menuTop + contentHeight < winHeight && !this.site.mobileView) { height = contentHeight + "px"; } else { height = winHeight - menuTop; @@ -180,6 +391,17 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { } $panel.width(width); + if (this._animate) { + $panel.addClass("animate"); + $headerCloak.addClass("animate"); + this._scheduledRemoveAnimate = Ember.run.later(() => { + $panel.removeClass("animate"); + $headerCloak.removeClass("animate"); + }, 200); + } + $panel.css({ right: "", left: "" }); + $headerCloak.css("opacity", 0.5); + this._animate = false; }); } }); diff --git a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 index c400737fbf..45a27b7ccd 100644 --- a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 @@ -96,13 +96,6 @@ export default Ember.Component.extend( return classes.join(" "); }, - titleColSpan: function() { - return !this.get("hideCategory") && - this.get("topic.isPinnedUncategorized") - ? 2 - : 1; - }.property("topic.isPinnedUncategorized"), - hasLikes: function() { return this.get("topic.like_count") > 0; }, diff --git a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 index b4b840cb6b..833ec837a8 100644 --- a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 @@ -1,6 +1,10 @@ import { observes } from "ember-addons/ember-computed-decorators"; import showModal from "discourse/lib/show-modal"; -import PanEvents from "discourse/mixins/pan-events"; +import PanEvents, { + SWIPE_VELOCITY, + SWIPE_DISTANCE_THRESHOLD, + SWIPE_VELOCITY_THRESHOLD +} from "discourse/mixins/pan-events"; export default Ember.Component.extend(PanEvents, { composerOpen: null, @@ -117,10 +121,13 @@ export default Ember.Component.extend(PanEvents, { } }, - _panOpenClose(offset, velocity, direction) { + _handlePanDone(offset, event) { const $timelineContainer = $(".timeline-container"); - const maxOffset = parseInt($timelineContainer.css("height")); - direction === "close" ? (offset += velocity) : (offset -= velocity); + const maxOffset = parseInt($timelineContainer.css("height"), 10); + + this._shouldPanClose(event) + ? (offset += SWIPE_VELOCITY) + : (offset -= SWIPE_VELOCITY); $timelineContainer.css("bottom", -offset); if (offset > maxOffset) { @@ -128,43 +135,43 @@ export default Ember.Component.extend(PanEvents, { } else if (offset <= 0) { $timelineContainer.css("bottom", ""); } else { - Ember.run.later( - () => this._panOpenClose(offset, velocity, direction), - 20 - ); + Ember.run.later(() => this._handlePanDone(offset, event), 20); } }, _shouldPanClose(e) { - return (e.deltaY > 200 && e.velocityY > -0.15) || e.velocityY > 0.15; + return ( + (e.deltaY > SWIPE_DISTANCE_THRESHOLD && + e.velocityY > -SWIPE_VELOCITY_THRESHOLD) || + e.velocityY > SWIPE_VELOCITY_THRESHOLD + ); }, panStart(e) { + e.originalEvent.preventDefault(); const center = e.center; const $centeredElement = $(document.elementFromPoint(center.x, center.y)); if ($centeredElement.parents(".timeline-scrollarea-wrapper").length) { - this.set("isPanning", false); + this.isPanning = false; } else if (e.direction === "up" || e.direction === "down") { - this.set("isPanning", true); + this.isPanning = true; } }, panEnd(e) { - if (!this.get("isPanning")) { + if (!this.isPanning) { return; } - this.set("isPanning", false); - if (this._shouldPanClose(e)) { - this._panOpenClose(e.deltaY, 40, "close"); - } else { - this._panOpenClose(e.deltaY, 40, "open"); - } + e.originalEvent.preventDefault(); + this.isPanning = false; + this._handlePanDone(e.deltaY, e); }, panMove(e) { - if (!this.get("isPanning")) { + if (!this.isPanning) { return; } + e.originalEvent.preventDefault(); $(".timeline-container").css("bottom", Math.min(0, -e.deltaY)); }, diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 042597354c..5e62a28d62 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -62,6 +62,12 @@ function loadDraft(store, opts) { const _popupMenuOptionsCallbacks = []; +let _checkDraftPopup = !Ember.testing; + +export function toggleCheckDraftPopup(enabled) { + _checkDraftPopup = enabled; +} + export function clearPopupMenuOptionsCallback() { _popupMenuOptionsCallbacks.length = 0; } @@ -770,23 +776,30 @@ export default Ember.Controller.extend({ .then(resolve, reject); } - // we need a draft sequence for the composer to work - if (opts.draftSequence === undefined) { - return Draft.get(opts.draftKey) - .then(function(data) { - opts.draftSequence = data.draft_sequence; - opts.draft = data.draft; - self._setModel(composerModel, opts); - }) - .then(resolve, reject); - } - if (composerModel) { if (composerModel.get("action") !== opts.action) { composerModel.setProperties({ unlistTopic: false, whisper: false }); } } + // check if there is another draft saved on server + // or get a draft sequence number + if (!opts.draft || opts.draftSequence === undefined) { + return Draft.get(opts.draftKey) + .then(data => self.confirmDraftAbandon(data)) + .then(data => { + opts.draft = opts.draft || data.draft; + + // we need a draft sequence for the composer to work + if (opts.draft_sequence === undefined) { + opts.draftSequence = data.draft_sequence; + } + + self._setModel(composerModel, opts); + }) + .then(resolve, reject); + } + self._setModel(composerModel, opts); resolve(); }); @@ -865,6 +878,41 @@ export default Ember.Controller.extend({ } }, + confirmDraftAbandon(data) { + if (!data.draft) { + return data; + } + + // do not show abandon dialog if old draft is clean + const draft = JSON.parse(data.draft); + if (draft.reply === draft.originalText) { + data.draft = null; + return data; + } + + if (_checkDraftPopup) { + return new Ember.RSVP.Promise(resolve => { + bootbox.dialog(I18n.t("drafts.abandon.confirm"), [ + { + label: I18n.t("drafts.abandon.no_value"), + callback: () => resolve(data) + }, + { + label: I18n.t("drafts.abandon.yes_value"), + class: "btn-danger", + callback: () => { + data.draft = null; + resolve(data); + } + } + ]); + }); + } else { + data.draft = null; + return data; + } + }, + cancelComposer() { return new Ember.RSVP.Promise(resolve => { if (this.get("model.hasMetaData") || this.get("model.replyDirty")) { diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 5ebe5ca905..8d5e89cf8c 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -111,7 +111,7 @@ const controllerOpts = { return ( (this.isFilterPage(this.get("model.filter"), "new") || this.isFilterPage(this.get("model.filter"), "unread")) && - this.get("model.topics.length") >= 30 + this.get("model.topics.length") >= 15 ); }.property("model.filter", "model.topics.length"), diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index 935d52c1a6..5ec24d7aca 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -243,7 +243,7 @@ export default Ember.Controller.extend( }, connectAccount(method) { - method.doLogin(); + method.doLogin(true); } } } diff --git a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 index 5e845a64c0..e9e69217da 100644 --- a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 +++ b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 @@ -1,5 +1,6 @@ import highlightSyntax from "discourse/lib/highlight-syntax"; import lightbox from "discourse/lib/lightbox"; +import { setupLazyLoading } from "discourse/lib/lazy-load-images"; import { setTextDirections } from "discourse/lib/text-direction"; import { withPluginApi } from "discourse/lib/plugin-api"; @@ -14,6 +15,8 @@ export default { api.decorateCooked(setTextDirections); } + setupLazyLoading(api); + api.decorateCooked($elem => { const players = $("audio", $elem); if (players.length) { diff --git a/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 new file mode 100644 index 0000000000..4f2b7729b9 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 @@ -0,0 +1,49 @@ +const OBSERVER_OPTIONS = { + rootMargin: "50%" // load images slightly before they're visible +}; + +// We hide an image by replacing it with a transparent gif +function hide(image) { + image.classList.add("d-lazyload"); + image.classList.add("d-lazyload-hidden"); + image.setAttribute("data-src", image.getAttribute("src")); + image.setAttribute( + "src", + "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" + ); +} + +// Restore an image from the `data-src` attribute +function show(image) { + let dataSrc = image.getAttribute("data-src"); + if (dataSrc) { + image.setAttribute("src", dataSrc); + image.classList.remove("d-lazyload-hidden"); + } +} + +export function setupLazyLoading(api) { + // Old IE don't support this API + if (!("IntersectionObserver" in window)) { + return; + } + + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + const { target } = entry; + + if (entry.isIntersecting) { + show(target); + observer.unobserve(target); + } else { + // The Observer is triggered when entries are added. This allows + // us to hide things that start off screen. + hide(target); + } + }); + }, OBSERVER_OPTIONS); + + api.decorateCooked($post => { + $(".lightbox img", $post).each((_, $img) => observer.observe($img)); + }); +} diff --git a/app/assets/javascripts/discourse/lib/tooltip.js.es6 b/app/assets/javascripts/discourse/lib/tooltip.js.es6 index 227e0f6c3c..ecf93d8c0c 100644 --- a/app/assets/javascripts/discourse/lib/tooltip.js.es6 +++ b/app/assets/javascripts/discourse/lib/tooltip.js.es6 @@ -3,8 +3,9 @@ import { escapeExpression } from "discourse/lib/utilities"; const fadeSpeed = 300; const tooltipID = "#discourse-tooltip"; -export function showTooltip($this) { - const $parent = $this.offsetParent(); +export function showTooltip(e) { + const $this = $(e.currentTarget), + $parent = $this.offsetParent(); // html tooltip are risky try your best to sanitize anything // displayed as html to avoid XSS attacks const content = $this.attr("data-tooltip") @@ -77,9 +78,7 @@ export function hideTooltip() { export function registerTooltip(jqueryContext) { if (jqueryContext.length) { - jqueryContext - .off("click") - .on("click", event => showTooltip($(event.currentTarget))); + jqueryContext.off("click").on("click", event => showTooltip(event)); } } diff --git a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 index b4247d861d..f3c1623ee6 100644 --- a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 +++ b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 @@ -1,3 +1,10 @@ +/** + Pan events is a mixin that allows components to detect and respond to swipe gestures + It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event. + **/ +export const SWIPE_VELOCITY = 40; +export const SWIPE_DISTANCE_THRESHOLD = 50; +export const SWIPE_VELOCITY_THRESHOLD = 0.1; export default Ember.Mixin.create({ //velocity is pixels per ms @@ -7,11 +14,23 @@ export default Ember.Mixin.create({ this._super(); if (this.site.mobileView) { - this.$() - .on("pointerdown", e => this._panStart(e)) - .on("pointermove", e => this._panMove(e)) - .on("pointerup", e => this._panMove(e)) - .on("pointercancel", e => this._panMove(e)); + if ("onpointerdown" in document.documentElement) { + this.$() + .on("pointerdown", e => this._panStart(e)) + .on("pointermove", e => this._panMove(e, e)) + .on("pointerup", e => this._panMove(e, e)) + .on("pointercancel", e => this._panMove(e, e)); + } else if ("ontouchstart" in document.documentElement) { + this.$() + .on("touchstart", e => this._panStart(e.touches[0])) + .on("touchmove", e => { + const touchEvent = e.touches[0]; + touchEvent.type = "pointermove"; + this._panMove(touchEvent, e); + }) + .on("touchend", e => this._panMove({ type: "pointerup" }, e)) + .on("touchcancel", e => this._panMove({ type: "pointercancel" }, e)); + } } }, @@ -23,7 +42,11 @@ export default Ember.Mixin.create({ .off("pointerdown") .off("pointerup") .off("pointermove") - .off("pointercancel"); + .off("pointercancel") + .off("touchstart") + .off("touchmove") + .off("touchend") + .off("touchcancel"); } }, @@ -44,6 +67,9 @@ export default Ember.Mixin.create({ } const newTimestamp = new Date().getTime(); const timeDiffSeconds = newTimestamp - oldState.timestamp; + if (timeDiffSeconds === 0) { + return oldState; + } //calculate delta x, y, distance from START location const deltaX = e.clientX - oldState.startLocation.x; const deltaY = e.clientY - oldState.startLocation.y; @@ -93,7 +119,7 @@ export default Ember.Mixin.create({ this.set("_panState", newState); }, - _panMove(e) { + _panMove(e, originalEvent) { if (!this.get("_panState")) { this._panStart(e); return; @@ -104,6 +130,7 @@ export default Ember.Mixin.create({ return; } this.set("_panState", newState); + newState.originalEvent = originalEvent; if (previousState.start && "panStart" in this) { this.panStart(newState); } else if ( diff --git a/app/assets/javascripts/discourse/models/login-method.js.es6 b/app/assets/javascripts/discourse/models/login-method.js.es6 index 0365a27658..7207ad5f3c 100644 --- a/app/assets/javascripts/discourse/models/login-method.js.es6 +++ b/app/assets/javascripts/discourse/models/login-method.js.es6 @@ -24,7 +24,7 @@ const LoginMethod = Ember.Object.extend({ ); }, - doLogin() { + doLogin(reconnect = false) { const name = this.get("name"); const customLogin = this.get("customLogin"); @@ -33,6 +33,10 @@ const LoginMethod = Ember.Object.extend({ } else { let authUrl = this.get("custom_url") || Discourse.getURL("/auth/" + name); + if (reconnect) { + authUrl += "?reconnect=true"; + } + if (this.get("full_screen_login")) { document.cookie = "fsl=true"; window.location = authUrl; @@ -45,7 +49,8 @@ const LoginMethod = Ember.Object.extend({ const width = this.get("frame_width") || 800; if (name === "facebook") { - authUrl = authUrl + "?display=popup"; + authUrl += authUrl.includes("?") ? "&" : "?"; + authUrl += "display=popup"; } const w = window.open( diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 3ea49fdc0b..4de7c3b347 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -369,10 +369,10 @@ Post.reopenClass({ }); }, - deleteMany(post_ids) { + deleteMany(post_ids, { deferFlags = false } = {}) { return ajax("/posts/destroy_many", { type: "DELETE", - data: { post_ids } + data: { post_ids, defer_flags: deferFlags } }); }, diff --git a/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 b/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 index e9af8e52a3..58cdc07833 100644 --- a/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 @@ -4,7 +4,7 @@ export default Ember.Object.extend({ return this.forceName; } - return I18n.t(this.name); + return this.name ? I18n.t(this.name) : ""; }.property(), sortIcon: function() { diff --git a/app/assets/javascripts/discourse/routes/login.js.es6 b/app/assets/javascripts/discourse/routes/login.js.es6 index 591b93af41..8465ceb6bc 100644 --- a/app/assets/javascripts/discourse/routes/login.js.es6 +++ b/app/assets/javascripts/discourse/routes/login.js.es6 @@ -1,11 +1,12 @@ import buildStaticRoute from "discourse/routes/build-static-route"; +import { defaultHomepage } from "discourse/lib/utilities"; const LoginRoute = buildStaticRoute("login"); LoginRoute.reopen({ beforeModel() { if (!this.siteSettings.login_required) { - this.replaceWith("discovery.latest").then(e => { + this.replaceWith(`/${defaultHomepage()}`).then(e => { Ember.run.next(() => e.send("showLogin")); }); } diff --git a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs index bf8fdeefbf..cee547d6b4 100644 --- a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs @@ -1,9 +1,7 @@ -{{ - category-drop +{{category-drop category=firstCategory categories=parentCategoriesSorted - countSubcategories=true -}} + countSubcategories=true}} {{#if childCategories}} {{category-drop diff --git a/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs b/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs index 9c082b2c57..7a793714c0 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs @@ -1,5 +1,5 @@ {{#each categories as |c|}} -
+
diff --git a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs index f133219f3a..8c51088578 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs @@ -1,5 +1,5 @@ {{#each categories as |c|}} -
+
- {{else}} + {{else}}
{{#if model.createdPost}} {{i18n 'composer.saved'}} @@ -197,7 +197,7 @@
{{composer-toggles composeState=model.composeState - toggleFullscreen=(action "fullscreenComposer") + toggleFullscreen=(action "openIfDraft") toggleComposer=(action "toggle") toggleToolbar=(action "toggleToolbar")}} diff --git a/app/assets/javascripts/discourse/templates/list/activity-column.raw.hbs b/app/assets/javascripts/discourse/templates/list/activity-column.raw.hbs index 5bb3d82280..cb31efc010 100644 --- a/app/assets/javascripts/discourse/templates/list/activity-column.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/activity-column.raw.hbs @@ -1 +1,6 @@ -<{{tagName}} class="{{class}} {{cold-age-class topic.createdAt startDate=topic.bumpedAt class=""}} activity" title="{{{topic.bumpedAtTitle}}}">{{format-date topic.bumpedAt format="tiny" noTitle="true"}} +<{{tagName}} class="{{class}} {{cold-age-class topic.createdAt startDate=topic.bumpedAt class=""}} activity" title="{{{topic.bumpedAtTitle}}}"> + + {{raw-plugin-outlet name="topic-list-before-relative-date"}} + {{format-date topic.bumpedAt format="tiny" noTitle="true"}} + + diff --git a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs index 34616743db..d0b1077dd4 100644 --- a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs @@ -11,7 +11,7 @@ This causes the topic-post-badge to be considered the same word as "text" at the end of the link, preventing it from line wrapping onto its own line. --}} - + {{~raw-plugin-outlet name="topic-list-before-status"}} {{~raw "topic-status" topic=topic}} @@ -24,20 +24,20 @@ {{~raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl newDotText=newDotText}} {{~/if}} - + -{{#unless hideCategory}} - {{#unless topic.isPinnedUncategorized}} - {{raw "list/category-column" category=topic.category}} - {{/unless}} -{{/unless}} - {{#if showPosters}} {{raw "list/posters-column" posters=topic.posters}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs index 361ffb91b9..54a7de63eb 100644 --- a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs +++ b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs @@ -6,11 +6,8 @@ {{/if}} {{raw "topic-list-header-column" order='default' name=listTitle bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect}} -{{#unless hideCategory}} - {{raw "topic-list-header-column" sortable=sortable order='category' name='category_title'}} -{{/unless}} {{#if showPosters}} - {{raw "topic-list-header-column" order='posters' name='users'}} + {{raw "topic-list-header-column" order='posters'}} {{/if}} {{raw "topic-list-header-column" sortable=sortable number='true' order='posts' name='replies'}} {{#if showParticipants}} diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 index 12b6dbea51..deb4f1cbfc 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 @@ -323,7 +323,32 @@ export default createWidget("hamburger-menu", { }); }, - clickOutside() { - this.sendWidgetAction("toggleHamburger"); + clickOutsideMobile(e) { + const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); + if ( + $centeredElement.parents(".panel").length && + !$centeredElement.hasClass("header-cloak") + ) { + this.sendWidgetAction("toggleHamburger"); + } else { + const $window = $(window); + const windowWidth = parseInt($window.width(), 10); + const $panel = $(".menu-panel"); + $panel.addClass("animate"); + const panelOffsetDirection = this.site.mobileView ? "left" : "right"; + $panel.css(panelOffsetDirection, -windowWidth); + const $headerCloak = $(".header-cloak"); + $headerCloak.addClass("animate"); + $headerCloak.css("opacity", 0); + Ember.run.later(() => this.sendWidgetAction("toggleHamburger"), 200); + } + }, + + clickOutside(e) { + if (this.site.mobileView) { + this.clickOutsideMobile(e); + } else { + this.sendWidgetAction("toggleHamburger"); + } } }); diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 078cecf5f4..80f213c4fc 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -253,6 +253,15 @@ createWidget("header-buttons", { } }); +createWidget("header-cloak", { + tagName: "div.header-cloak", + html() { + return ""; + }, + click() {}, + scheduleRerender() {} +}); + const forceContextEnabled = ["category", "user", "private_messages"]; let additionalPanels = []; @@ -315,6 +324,9 @@ export default createWidget("header", { } else if (state.userVisible) { panels.push(this.attach("user-menu")); } + if (this.site.mobileView) { + panels.push(this.attach("header-cloak")); + } additionalPanels.map(panel => { if (this.state[panel.toggle]) { @@ -348,6 +360,7 @@ export default createWidget("header", { this.state.userVisible = false; this.state.hamburgerVisible = false; this.state.searchVisible = false; + this.toggleBodyScrolling(false); }, linkClickedEvent(attrs) { @@ -416,10 +429,36 @@ export default createWidget("header", { } this.state.userVisible = !this.state.userVisible; + this.toggleBodyScrolling(this.state.userVisible); }, toggleHamburger() { this.state.hamburgerVisible = !this.state.hamburgerVisible; + this.toggleBodyScrolling(this.state.hamburgerVisible); + }, + + toggleBodyScrolling(bool) { + if (!this.site.mobileView) return; + if (bool) { + document.body.addEventListener("touchmove", this.preventDefault, { + passive: false + }); + } else { + document.body.removeEventListener("touchmove", this.preventDefault, { + passive: false + }); + } + }, + + preventDefault(e) { + // prevent all scrollin on menu panels, except on overflow + const height = window.innerHeight ? window.innerHeight : $(window).height(); + if ( + !$(e.target).parents(".menu-panel").length || + $(".menu-panel .panel-body-contents").height() <= height + ) { + e.preventDefault(); + } }, togglePageSearch() { diff --git a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 index bbbf95d275..bf3a323752 100644 --- a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 @@ -104,6 +104,11 @@ export default class PostCooked { valid = href.indexOf(lc.url) >= 0; } + // Match server-side behaviour for internal links with query params + if (lc.internal && /\?/.test(href)) { + valid = href.split("?")[0] === lc.url; + } + // don't display badge counts on category badge & oneboxes (unless when explicitely stated) if (valid && isValidLink($link)) { const title = I18n.t("topic_map.clicks", { count: lc.clicks }); diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 index 69d987db8f..d0261e2d75 100644 --- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 @@ -194,7 +194,31 @@ export default createWidget("user-menu", { }); }, - clickOutside() { - this.sendWidgetAction("toggleUserMenu"); + clickOutsideMobile(e) { + const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); + if ( + $centeredElement.parents(".panel").length && + !$centeredElement.hasClass("header-cloak") + ) { + this.sendWidgetAction("toggleUserMenu"); + } else { + const $window = $(window); + const windowWidth = parseInt($window.width(), 10); + const $panel = $(".menu-panel"); + $panel.addClass("animate"); + $panel.css("right", -windowWidth); + const $headerCloak = $(".header-cloak"); + $headerCloak.addClass("animate"); + $headerCloak.css("opacity", 0); + Ember.run.later(() => this.sendWidgetAction("toggleUserMenu"), 200); + } + }, + + clickOutside(e) { + if (this.site.mobileView) { + this.clickOutsideMobile(e); + } else { + this.sendWidgetAction("toggleUserMenu"); + } } }); diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 index 0ab9cf8709..548cf74638 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 @@ -57,7 +57,7 @@ function imageFor(code, opts) { } } -function getEmojiName(content, pos, state) { +function getEmojiName(content, pos, state, inlineEmoji) { if (content.charCodeAt(pos) !== 58) { return; } @@ -65,6 +65,7 @@ function getEmojiName(content, pos, state) { if (pos > 0) { let prev = content.charCodeAt(pos - 1); if ( + !inlineEmoji && !state.md.utils.isSpace(prev) && !state.md.utils.isPunctChar(String.fromCharCode(prev)) ) { @@ -173,7 +174,13 @@ function getEmojiTokenByTranslation(content, pos, state) { } } -function applyEmoji(content, state, emojiUnicodeReplacer, enableShortcuts) { +function applyEmoji( + content, + state, + emojiUnicodeReplacer, + enableShortcuts, + inlineEmoji +) { let i; let result = null; let contentToken = null; @@ -188,7 +195,7 @@ function applyEmoji(content, state, emojiUnicodeReplacer, enableShortcuts) { for (i = 0; i < content.length - 1; i++) { let offset = 0; - let emojiName = getEmojiName(content, i, state); + const emojiName = getEmojiName(content, i, state, inlineEmoji); let token = null; if (emojiName) { @@ -235,6 +242,7 @@ export function setup(helper) { helper.registerOptions((opts, siteSettings, state) => { opts.features.emoji = !!siteSettings.enable_emoji; opts.features.emojiShortcuts = !!siteSettings.enable_emoji_shortcuts; + opts.features.inlineEmoji = !!siteSettings.enable_inline_emoji_translation; opts.emojiSet = siteSettings.emoji_set || ""; opts.customEmoji = state.customEmoji; }); @@ -246,7 +254,8 @@ export function setup(helper) { c, s, md.options.discourse.emojiUnicodeReplacer, - md.options.discourse.features.emojiShortcuts + md.options.discourse.features.emojiShortcuts, + md.options.discourse.features.inlineEmoji ) ) ); diff --git a/app/assets/javascripts/pretty-text/white-lister.js.es6 b/app/assets/javascripts/pretty-text/white-lister.js.es6 index c303283e36..f8641261da 100644 --- a/app/assets/javascripts/pretty-text/white-lister.js.es6 +++ b/app/assets/javascripts/pretty-text/white-lister.js.es6 @@ -183,6 +183,7 @@ const DEFAULT_LIST = [ "small", "span[lang]", "span.excerpt", + "div.excerpt", "span.hashtag", "span.mention", "strike", diff --git a/app/assets/javascripts/select-kit/components/category-drop.js.es6 b/app/assets/javascripts/select-kit/components/category-drop.js.es6 index fdaa6d10a1..0fd2d18751 100644 --- a/app/assets/javascripts/select-kit/components/category-drop.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-drop.js.es6 @@ -17,7 +17,6 @@ export default ComboBoxComponent.extend({ tagName: "li", categoryStyle: Ember.computed.alias("siteSettings.category_style"), noCategoriesLabel: I18n.t("categories.no_subcategory"), - mutateAttributes() {}, fullWidthOnMobile: true, caretDownIcon: "caret-right", caretUpIcon: "caret-down", @@ -53,14 +52,7 @@ export default ComboBoxComponent.extend({ }, init() { - this._super(); - - if (this.get("category")) { - this.set("value", this.get("category.id")); - } else { - this.set("value", null); - } - if (!this.get("categories")) this.set("categories", []); + this._super(...arguments); this.get("rowComponentOptions").setProperties({ hideParentCategory: this.get("subCategory"), @@ -73,6 +65,11 @@ export default ComboBoxComponent.extend({ }); }, + didReceiveAttrs() { + if (!this.get("categories")) this.set("categories", []); + this.forceValue(this.get("category.id")); + }, + @computed("content") filterable(content) { const contentLength = (content && content.length) || 0; diff --git a/app/assets/javascripts/select-kit/components/multi-select.js.es6 b/app/assets/javascripts/select-kit/components/multi-select.js.es6 index ed64094062..2f83643cd9 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -115,6 +115,11 @@ export default SelectKitComponent.extend({ }, mutateContent() {}, + forceValues(values) { + this.mutateValues(values); + this._compute(); + }, + filterComputedContent(computedContent, computedValues, filter) { return computedContent.filter(c => { return this._normalize(get(c, "name")).indexOf(filter) > -1; diff --git a/app/assets/javascripts/select-kit/components/single-select.js.es6 b/app/assets/javascripts/select-kit/components/single-select.js.es6 index 66e13bff38..0c63b9f8b5 100644 --- a/app/assets/javascripts/select-kit/components/single-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/single-select.js.es6 @@ -56,6 +56,11 @@ export default SelectKitComponent.extend({ this.set("value", computedValue); }, + forceValue(value) { + this.mutateValue(value); + this._compute(); + }, + _beforeWillComputeValue(value) { if ( !isEmpty(this.get("content")) && diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 index e805efe162..d490fa0abe 100644 --- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 @@ -260,6 +260,7 @@ export default Ember.Mixin.create({ display: "inline-block", width, height, + "margin-bottom": this.$().css("margin-bottom"), "vertical-align": "middle" }); diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index 66e8359459..bcf1f0d72f 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -32,3 +32,4 @@ //= require virtual-dom-amd //= require highlight.js //= require htmlparser.js +//= require intersection-observer diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index db101eb431..1ba8f9a6c8 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -39,6 +39,10 @@ @include active-navigation-item; } + &.dashboard-next-security .navigation-item.security { + @include active-navigation-item; + } + &.general .navigation-item.general { @include active-navigation-item; } @@ -488,14 +492,8 @@ margin-bottom: 1.5em; } -.dashboard-next-moderation { - .admin-dashboard-moderation-top { - display: grid; - grid-template-columns: repeat(12, 1fr); - grid-column-gap: 1em; - grid-row-gap: 1em; - } - +.dashboard-next-moderation, +.dashboard-next-security { .section-body { margin-bottom: 1em; } @@ -510,6 +508,7 @@ grid-column: span 12; } + .admin-dashboard-security-bottom-outlet, .admin-dashboard-moderation-bottom-outlet { display: grid; grid-template-columns: repeat(12, 1fr); @@ -518,11 +517,16 @@ } } - .admin-report.flags-status { - grid-column: span 12; - } - - .admin-report.post-edits { + .admin-report { grid-column: span 12; } } + +.dashboard-next-moderation { + .admin-dashboard-moderation-top { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + grid-row-gap: 1em; + } +} diff --git a/app/assets/stylesheets/common/admin/emails.scss b/app/assets/stylesheets/common/admin/emails.scss index d7bb83c5de..732f3d40bb 100644 --- a/app/assets/stylesheets/common/admin/emails.scss +++ b/app/assets/stylesheets/common/admin/emails.scss @@ -83,3 +83,15 @@ border-width: 1px; } } + +.email-advanced-test { + .admin-controls { + display: block; + } + + .email-body { + width: 95%; + height: 150px; + font-family: monospace; + } +} diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index e29caaaae4..8bf1386fd2 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -131,6 +131,14 @@ @extend .topic-list-main-link; } + .link-bottom-line { + font-size: $font-down-1; + a.badge-wrapper.box { + padding-top: 0; + padding-bottom: 0; + } + } + .topic-featured-link { padding-left: 5px; } @@ -144,7 +152,7 @@ .topic-excerpt { font-size: $font-down-1; margin-top: 5px; - color: dark-light-choose($primary-high, $secondary-high); + color: $primary-high; word-wrap: break-word; line-height: $line-height-large; padding-right: 20px; @@ -152,14 +160,6 @@ .topic-statuses:empty { display: none; } - .topic-status { - margin-right: 4px; - padding: 0; - font-size: 1.071em; - &:last-of-type { - margin-right: 0; - } - } .num { text-align: center; @@ -253,14 +253,6 @@ ol.category-breadcrumb { .d-icon-thumbtack.unpinned { @include fa-rotate(180deg, 1); - color: $primary; - /* because it is rotated, right becomes left! */ - padding-left: 3px; - padding-right: 0 !important; -} - -.topic-statuses .fa { - padding-right: 3px; } .top-title-buttons { diff --git a/app/assets/stylesheets/common/base/category-list.scss b/app/assets/stylesheets/common/base/category-list.scss index 82d3556f27..4abcf9f3dd 100644 --- a/app/assets/stylesheets/common/base/category-list.scss +++ b/app/assets/stylesheets/common/base/category-list.scss @@ -55,10 +55,6 @@ max-width: 100%; } } - - h3 .fa { - color: $primary; - } } .category-box-inner { @@ -143,7 +139,7 @@ display: inline-block; margin-right: 0.6em; } - .logo { + .logo img { display: inline-block; height: 20px; width: 20px; @@ -186,16 +182,34 @@ li { padding: 4px 0; display: flex; + align-items: baseline; .overflow { max-height: 3em; overflow: hidden; text-overflow: ellipsis; } - .d-icon { - margin-right: 6px; - margin-top: 2px; + margin-right: 0.15em; + width: 0.76em; + height: 0.76em; } } } } + +.categories-list .category { + h3 .d-icon { + color: $primary-medium; + height: 0.76em; + width: 0.76em; + vertical-align: baseline; + margin-right: 0.15em; + } +} + +.category-boxes-with-topics, +.category-boxes { + .category-box h3 .d-icon { + margin-right: 0; + } +} diff --git a/app/assets/stylesheets/common/base/code_highlighting.scss b/app/assets/stylesheets/common/base/code_highlighting.scss index bfd0866d9c..2e09912174 100644 --- a/app/assets/stylesheets/common/base/code_highlighting.scss +++ b/app/assets/stylesheets/common/base/code_highlighting.scss @@ -75,7 +75,7 @@ github.com style (c) Vasily Polovnyov } .hljs-regexp { - color: #009926; + color: $success; } .hljs-symbol, @@ -90,24 +90,24 @@ github.com style (c) Vasily Polovnyov .lisp .hljs-title, .clojure .hljs-built_in, .hljs-builtin-name { - color: #0086b3; + color: $tertiary-high; } .meta { - color: #999; + color: $primary-medium; font-weight: bold; } .hljs-deletion { - background: #fdd; + background: $danger-low; } .hljs-addition { - background: #dfd; + background: $success-low; } .diff .hljs-meta { - color: #aaa; + color: $primary-low; } /* diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index c7540f0254..24a7bc1661 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -668,3 +668,25 @@ select { } } } + +.topic-statuses { + display: inline; + float: left; + margin-right: 0.15em; + .topic-status { + margin: 0; + display: inline-flex; + color: $primary-medium; + .d-icon { + height: 0.76em; + width: 0.75em; + } + &:not(:last-child) { + margin-right: 0.15em; + } + } + + .d-icon-envelope { + color: $danger; + } +} diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss index 8d8cd19f7f..710027de16 100644 --- a/app/assets/stylesheets/common/base/emoji.scss +++ b/app/assets/stylesheets/common/base/emoji.scss @@ -4,6 +4,13 @@ img.emoji { vertical-align: middle; } +small img.emoji, +sub img.emoji, +sup img.emoji { + height: 1.1em; + width: 1.1em; +} + .emoji-picker { background-clip: padding-box; z-index: z("modal", "content"); diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index 9c2e433469..5d9e5ea2ba 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -48,6 +48,7 @@ } $size: 50px; + $icon-size: $size / 1.8; .avatar-flair-image { width: $size; @@ -61,10 +62,11 @@ display: flex; align-items: center; justify-content: center; + background-repeat: no-repeat; .d-icon { - height: $size / 1.8; - width: $size / 1.8; + height: $icon-size; + width: $icon-size; } } } diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss index e02e91906e..883a157976 100644 --- a/app/assets/stylesheets/common/base/groups.scss +++ b/app/assets/stylesheets/common/base/groups.scss @@ -218,6 +218,7 @@ } $size: 40px; + $icon-size: $size / 1.8; .avatar-flair { background-size: $size; @@ -227,10 +228,11 @@ display: flex; align-items: center; justify-content: center; + background-repeat: no-repeat; .d-icon { - height: $size / 1.8; - width: $size / 1.8; + height: $icon-size; + width: $icon-size; } } diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index 1099e43cd3..1a7708a1c6 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -9,6 +9,7 @@ box-shadow: shadow("header"); > .wrap { + box-sizing: border-box; width: 100%; height: 100%; .contents { @@ -56,6 +57,8 @@ } .header-buttons { + display: flex; + align-items: center; margin-top: 0.2em; } @@ -231,21 +234,12 @@ text-overflow: ellipsis; } .topic-statuses { - margin-top: -2px; - float: left; - padding: 0; - i { - color: $header_primary; + .d-icon { + color: $header_primary-medium; } .d-icon-envelope { color: $danger; } - .d-icon-lock { - padding-top: 0.15em; - } - .unpinned { - color: $header_primary; - } } h1 { margin: 0 0 0.25em 0; @@ -264,6 +258,9 @@ } .badge-wrapper { margin-right: 8px; + &.bullet { + padding-top: 1px; // alignment hack + } } .badge-wrapper.bullet { .badge-category-parent-bg, @@ -271,12 +268,22 @@ min-width: 5px; } } + .badge-wrapper { + &.bullet, + &.bar, + &.none { + span.badge-category { + color: $header_primary-high; + } + } + } .topic-header-extra { display: inline-flex; align-items: center; max-width: 100%; flex: 1 0 0%; // unit on flex-basis is required for IE11 .discourse-tags { + color: $header_primary-high; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; diff --git a/app/assets/stylesheets/common/base/lightbox.scss b/app/assets/stylesheets/common/base/lightbox.scss index 4f96f0592d..7fc53af519 100644 --- a/app/assets/stylesheets/common/base/lightbox.scss +++ b/app/assets/stylesheets/common/base/lightbox.scss @@ -1,15 +1,28 @@ -.lightbox { +.lightbox-wrapper .lightbox { position: relative; display: inline-block; - + background: $primary-low; &:hover .meta { opacity: 0.9; transition: opacity 0.5s; } } +.d-lazyload-hidden { + opacity: 0; + box-sizing: border-box; +} + +.cooked img.d-lazyload { + transition: opacity 0.4s 0.75s ease; +} + .lightbox-wrapper { display: inline-block; + img { + object-fit: cover; + object-position: top; + } &, * { outline: 0; @@ -60,8 +73,9 @@ right: 7px; &:before { // ideally, the SVG used here should be in HTML and reference the SVG sprite + // the SVG used here is the "expand" icon from FontAwesome 4.7.0 content: svg-uri( - ' ' + '' ); opacity: 0.8; } diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 5ac03f7445..016986ba49 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -2,6 +2,9 @@ position: fixed; right: 0; box-shadow: shadow("header"); + &.animate { + transition: right 0.2s ease-out, left 0.2s ease-out; + } .panel-body { position: absolute; @@ -10,6 +13,9 @@ width: 97%; } } +.header-cloak { + display: none; +} .menu-panel.drop-down { position: absolute; @@ -42,6 +48,7 @@ } .panel-body { + touch-action: pan-y pinch-zoom; overflow-y: auto; overflow-x: hidden; } diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 1858c06ddc..e9017d2112 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -334,6 +334,25 @@ } } +.delete-posts-progress { + .progress-bar { + height: 15px; + position: relative; + background: $primary-low-mid; + border-radius: 25px; + overflow: hidden; + margin: 30px 0 20px; + span { + display: block; + width: 0%; + height: 100%; + background-color: $tertiary; + position: relative; + transition: width 0.6s linear; + } + } +} + #invite-modal { overflow: visible; diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index 31043cc5fe..6832098e44 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -549,6 +549,10 @@ aside.onebox.stackexchange .onebox-body { .retweet { color: dark-light-choose($primary-medium, $secondary-medium); padding-left: 10px; + svg { + fill: currentColor; + vertical-align: middle; + } } } diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index c8a85d84dc..4e67a1a232 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -57,6 +57,9 @@ .main-results { display: flex; flex: 1 1 auto; + .topic-statuses { + color: $primary-medium; + } } .main-results + .secondary-results { @@ -190,16 +193,5 @@ .topic-title { margin-right: 0.25em; } - - .topic-statuses { - float: none; - display: inline-block; - color: dark-light-choose($primary-medium, $secondary-medium); - margin: 0; - - .fa { - margin: 0; - } - } } } diff --git a/app/assets/stylesheets/common/base/search.scss b/app/assets/stylesheets/common/base/search.scss index 1d030cf2a9..18bfe3ceb0 100644 --- a/app/assets/stylesheets/common/base/search.scss +++ b/app/assets/stylesheets/common/base/search.scss @@ -220,17 +220,18 @@ color: $tertiary-high; } .search-link { - .topic-statuses, .topic-title { font-size: $font-up-2; - line-height: $line-height-large; + line-height: $line-height-medium; } - .topic-statuses { - float: none; display: inline-block; - color: dark-light-choose($primary-medium, $secondary-medium); - font-size: $font-0; + font-size: 1.3em; + line-height: $line-height-medium; + color: $primary-medium; + span { + line-height: 1; + } } } .blurb { diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 9f78dc5ae1..e85e138a28 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -80,7 +80,7 @@ $tag-color: $primary-medium; } .extra-info-wrapper & { - color: $header-primary !important; + color: $header-primary_high !important; } &.box { @@ -124,10 +124,9 @@ $tag-color: $primary-medium; } .topic-list-item .discourse-tags { - display: block; - font-size: $font-down-2; + display: inline-block; font-weight: normal; - clear: both; + font-size: $font-down-1; .discourse-tag.box { position: relative; diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 557d48d880..9ddaf96644 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -167,7 +167,7 @@ .post-infos { display: flex; flex: 0 0 auto; - align-items: baseline; + align-items: center; } } @@ -233,8 +233,9 @@ aside.quote { .quote-controls { float: right; display: flex; - .d-icon { - margin-left: 0.2em; + align-items: center; + a { + margin-left: 0.3em; } } @@ -509,12 +510,6 @@ aside.quote { } } -// this ensures consistent top margin on topic posts even if the first line of a post -// is a top-margin-less element like a list or image. -.topic-body .regular { - margin-top: 15px; -} - .post-info { flex: 0 0 auto; margin-right: 0.5em; diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index d560460398..4b3db86c9e 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -66,6 +66,13 @@ a.topic-featured-link { display: inline-block; } + .topic-statuses { + line-height: 1.2em; + margin-right: 0.15em; + .d-icon { + color: $primary-medium; + } + } } h1 { diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index 1ac4bab3ec..332189afb9 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -206,15 +206,19 @@ } &.medium { - vertical-align: top; - flex: 0 0 32%; + flex: 0 0 auto; + width: 32%; margin-right: calc(2% - 3px); - &:nth-of-type(3n) { - margin-right: 0; + @media screen and (min-width: 851px) { + &:nth-of-type(3n) { + margin-right: 0; + } } @include breakpoint(medium) { - flex: 0 0 49%; - margin-right: 0; + width: 48.5%; + &:nth-of-type(2n) { + margin-right: 0; + } } @include breakpoint(mobile) { flex: 0 0 100%; @@ -225,9 +229,6 @@ &:active { box-shadow: none; } - @include breakpoint(mobile-small) { - width: 100%; - } } &.large { width: 100%; diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 875b976b96..fef2b60148 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -157,8 +157,8 @@ } a { - padding: 5px 10px; margin-bottom: 10px; + line-height: $line-height-medium; } } diff --git a/app/assets/stylesheets/common/components/badges.scss b/app/assets/stylesheets/common/components/badges.scss index e9b2114fa5..cb1cbe4ee3 100644 --- a/app/assets/stylesheets/common/components/badges.scss +++ b/app/assets/stylesheets/common/components/badges.scss @@ -31,8 +31,9 @@ overflow: hidden; } .d-icon { - padding-left: 1px; // prevents lock icon from being cut off on the left margin-right: 3px; + width: 0.74em; + height: 0.74em; } } @@ -41,7 +42,7 @@ &.bullet { margin-right: 12px; span.badge-category { - color: $primary; + color: $primary-high; overflow: hidden; text-overflow: ellipsis; .extra-info-wrapper & { @@ -63,6 +64,9 @@ width: 5px; } } + .d-icon { + color: $primary-medium; + } } // ----- Box @@ -110,7 +114,7 @@ margin-right: 5px; span.badge-category { - color: $primary; + color: $primary-high; padding: 1px 3px; overflow: hidden; text-overflow: ellipsis; @@ -135,7 +139,7 @@ // ----- No category style &.none { - color: $primary; + color: $primary-high; margin-right: 5px; } } diff --git a/app/assets/stylesheets/common/components/svg.scss b/app/assets/stylesheets/common/components/svg.scss index 40e805dc28..d4b88f1b2f 100644 --- a/app/assets/stylesheets/common/components/svg.scss +++ b/app/assets/stylesheets/common/components/svg.scss @@ -9,11 +9,6 @@ fill: currentColor; flex-shrink: 0; // Prevent the icon from shrinking if it's in a flexbox overflow: visible; - - &.d-icon-lock { - height: 0.9em; - width: 0.9em; - } } // Stacked Icons diff --git a/app/assets/stylesheets/common/components/user-info.scss b/app/assets/stylesheets/common/components/user-info.scss index 0dbacd65a6..a71e7ef1fc 100644 --- a/app/assets/stylesheets/common/components/user-info.scss +++ b/app/assets/stylesheets/common/components/user-info.scss @@ -7,6 +7,12 @@ .user-image { float: left; padding-right: 4px; + margin-right: 10px; + } + + .user-image-inner { + position: relative; + display: inline-block; } .user-detail { @@ -39,6 +45,18 @@ } } + .avatar-flair { + background-repeat: no-repeat; + background-position: center; + position: absolute; + bottom: -2px; + right: -8px; + background-size: 18px 18px; + border-radius: 12px; + width: 24px; + height: 24px; + } + &.small { width: 333px; @media screen and (max-width: $small-width) { diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index 7ddc1e17c2..dd8edcb288 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -199,12 +199,17 @@ $primary-very-low: dark-light-diff($primary, $secondary, 97%, -80%); $primary-low: dark-light-diff($primary, $secondary, 90%, -65%); $primary-low-mid: dark-light-diff($primary, $secondary, 70%, -45%); $primary-medium: dark-light-diff($primary, $secondary, 50%, -35%); -$primary-high: dark-light-diff($primary, $secondary, 30%, -10%); +$primary-high: dark-light-diff($primary, $secondary, 30%, -25%); //header_primary $header_primary-low: dark-light-diff($header_primary, $secondary, 90%, -65%); -$header_primary-medium: dark-light-diff($header_primary, $secondary, 50%, -20%); -$header_primary-high: dark-light-diff($header_primary, $secondary, 20%, 20%); +$header_primary-medium: dark-light-diff($header_primary, $secondary, 50%, -35%); +$header_primary-high: dark-light-diff( + $header_primary, + $header_background, + 30%, + -25% +); //secondary $secondary-low: dark-light-diff($secondary, $primary, 70%, -70%); diff --git a/app/assets/stylesheets/desktop/category-list.scss b/app/assets/stylesheets/desktop/category-list.scss index 31f6a01ca8..fbccd13f44 100644 --- a/app/assets/stylesheets/desktop/category-list.scss +++ b/app/assets/stylesheets/desktop/category-list.scss @@ -62,22 +62,23 @@ margin: 10px 0 0; display: flex; align-items: center; - flex-wrap: wrap; - &:first-of-type { margin-top: 13px; } - a.last-posted-at, a.last-posted-at:visited { font-size: $font-down-1; color: dark-light-choose($primary-medium, $secondary-high); } - - .topic-statuses .fa { - color: dark-light-choose($primary-medium, $secondary-high); + .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 0 1 auto; + } + .topic-statuses { + margin-right: 0.15em; } - .topic-post-badges .badge.new-posts, .title { margin-right: 5px; @@ -93,9 +94,6 @@ a[href] { color: $primary; } - .fa { - margin-right: 5px; - } } } @@ -128,8 +126,11 @@ } } - .discourse-tag { - font-size: $font-down-2; + .discourse-tags { + display: inline-block; + .discourse-tag { + font-size: $font-down-1; + } } .topic-featured-link { diff --git a/app/assets/stylesheets/desktop/components/user-info.scss b/app/assets/stylesheets/desktop/components/user-info.scss index ba13a6e182..d135a42763 100644 --- a/app/assets/stylesheets/desktop/components/user-info.scss +++ b/app/assets/stylesheets/desktop/components/user-info.scss @@ -12,6 +12,7 @@ } .user-image { width: 55px; + margin-right: 0; } } } diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss index 35314c0f21..b5c4dcb651 100644 --- a/app/assets/stylesheets/desktop/discourse.scss +++ b/app/assets/stylesheets/desktop/discourse.scss @@ -13,25 +13,6 @@ body.widget-dragging { height: 100%; } -.topic-statuses { - float: left; - padding: 0; - - .topic-status { - padding: 0 2px 0 0; - margin: 0; - line-height: $line-height-small; - - .d-icon { - font-size: $font-down-1; - } - } - - .d-icon-envelope { - color: $danger; - } -} - .form-vertical { .control-group { margin-bottom: 24px; diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index fb82703b99..b6c5fb6f6d 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -211,6 +211,7 @@ button.dismiss-read { .category-heading { clear: both; + max-width: 100%; p { line-height: $line-height-large; font-size: $font-up-3; @@ -235,9 +236,9 @@ button.dismiss-read { .category-logo.aspect-image { --max-height: 150px; max-height: var(--max-height); - width: calc(var(--max-height) * var(--aspect-ratio)); - max-width: 25%; + max-width: 60%; height: auto; + width: calc(var(--max-height) * var(--aspect-ratio)); img { width: 100%; @@ -280,10 +281,6 @@ button.dismiss-read { padding: 10px; font-size: $font-0; } - .category { - min-width: 0; - padding: 0; - } // suppress views column th.views { display: none; diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index aa86b3fd2b..7cfbad661c 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -6,12 +6,12 @@ margin-left: 0; } -h1 .topic-statuses .topic-status .d-icon { - font-size: 0.857em; - vertical-align: middle; -} - .topic-body { + // this ensures consistent top margin on topic posts even if the first line of a post + // is a top-margin-less element like a list or image. + .regular { + margin-top: 15px; + } padding: 0; &:first-of-type { border-top: none; @@ -444,10 +444,6 @@ nav.post-controls { padding: 20px 0 15px 0; table { margin-top: 10px; - } // this forces category to take less space in suggested topics - // as the poster list is not present at all there. - th.category { - width: 150px; } } @@ -508,17 +504,11 @@ video { } .topic-statuses { .d-icon { - color: $header_primary; + color: $header_primary-medium; } .d-icon-envelope { color: $danger; } - .d-icon-lock { - padding-top: 0.15em; - } - .unpinned { - color: $header_primary; - } } .topic-link { color: $header_primary; @@ -541,9 +531,6 @@ video { line-height: $line-height-large; width: 100%; } - .topic-statuses { - margin-top: -2px; - } } /* override docked header CSS for topics with categories */ diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index d7a7ce8981..a3ce6554e7 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -33,9 +33,6 @@ color: $primary; } } - .topic-statuses { - margin-top: -2px; - } .private-message-glyph { display: none; } diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 6ceea64ddf..f7b80208ce 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -196,7 +196,7 @@ text-align: right; a { - width: 140px; + min-width: 140px; } .right { diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index 138123ecbb..164d1cbbc8 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -29,6 +29,7 @@ @import "mobile/admin_report"; @import "mobile/admin_report_table"; @import "mobile/admin_report_counters"; +@import "mobile/menu-panel"; // Import all component-specific files @import "mobile/components/*"; diff --git a/app/assets/stylesheets/mobile/components/user-stream-item.scss b/app/assets/stylesheets/mobile/components/user-stream-item.scss index 0b0e0ceba2..0960cd470c 100644 --- a/app/assets/stylesheets/mobile/components/user-stream-item.scss +++ b/app/assets/stylesheets/mobile/components/user-stream-item.scss @@ -28,8 +28,4 @@ vertical-align: inherit; } } - - .topic-statuses { - float: left; - } } diff --git a/app/assets/stylesheets/mobile/discourse.scss b/app/assets/stylesheets/mobile/discourse.scss index 25816c870f..e64856cc36 100644 --- a/app/assets/stylesheets/mobile/discourse.scss +++ b/app/assets/stylesheets/mobile/discourse.scss @@ -44,20 +44,6 @@ blockquote { margin-bottom: 9px; } -.topic-statuses { - display: inline-block; - - .topic-status { - .d-icon { - color: $secondary-high; - } - } - - &:empty { - display: none; - } -} - // categories should not be bold on mobile; they fight with the topic title too much .badge-wrapper { font-weight: normal; @@ -92,16 +78,16 @@ blockquote { } > li > a { + display: flex; + align-items: center; padding: 8px 10px; height: 100%; width: 100%; box-sizing: border-box; - display: block; } .d-icon-caret-down { - position: absolute; - right: 0; + margin-left: auto; } .drop { diff --git a/app/assets/stylesheets/mobile/header.scss b/app/assets/stylesheets/mobile/header.scss index 5268e25dd0..e7efb8ce8d 100644 --- a/app/assets/stylesheets/mobile/header.scss +++ b/app/assets/stylesheets/mobile/header.scss @@ -71,7 +71,3 @@ .search-link .badge-category { display: none; } - -.search-link .topic-statuses .topic-status .d-icon { - font-size: $font-0; -} diff --git a/app/assets/stylesheets/mobile/lightbox.scss b/app/assets/stylesheets/mobile/lightbox.scss index 198dc51d73..5f3d98bfc7 100644 --- a/app/assets/stylesheets/mobile/lightbox.scss +++ b/app/assets/stylesheets/mobile/lightbox.scss @@ -1,6 +1,7 @@ .lightbox .meta, .lightbox:hover .meta { opacity: 0.7; + transition: none; } .meta { @@ -21,5 +22,15 @@ .expand { position: initial; + float: none; + height: 16px; + &:before { + // ideally, the SVG used here should be in HTML and reference the SVG sprite + // the SVG used here is the "expand" icon from FontAwesome 4.7.0 + content: svg-uri( + '' + ); + opacity: inherit; + } } } diff --git a/app/assets/stylesheets/mobile/menu-panel.scss b/app/assets/stylesheets/mobile/menu-panel.scss new file mode 100644 index 0000000000..9392ae7898 --- /dev/null +++ b/app/assets/stylesheets/mobile/menu-panel.scss @@ -0,0 +1,17 @@ +.hamburger-panel .menu-panel.slide-in { + left: 0; +} +.header-cloak { + height: 100vh; + width: 100vw; + position: fixed; + background-color: black; + opacity: 0.5; + top: 0; + left: 0; + display: none; + touch-action: pan-y pinch-zoom; + &.animate { + transition: opacity 0.2s ease-out; + } +} diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss index 8d9d4b24ec..b80e3dc194 100644 --- a/app/assets/stylesheets/mobile/topic-list.scss +++ b/app/assets/stylesheets/mobile/topic-list.scss @@ -152,6 +152,12 @@ color: $primary; padding: 0.5em 0.667em 1.2em 0; } + .topic-statuses { + a { + line-height: 0.8; + color: $primary-medium; + } + } } .badge-notification, @@ -273,9 +279,10 @@ tr.category-topic-link { border-top: 1px solid; h3 { + max-width: 100%; display: inline-block; font-size: $font-up-2; - margin: 0 0 0 10px; + padding: 0 0 0 10px; .d-icon { margin-right: 5px; } diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 1a3c493c88..5db0b876f7 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -202,6 +202,10 @@ sub sub { width: 100%; margin-top: 0; } + + .topic-statuses { + line-height: $line-height-small; + } } // make mobile timeline top and bottom dates easier to select diff --git a/app/controllers/admin/dashboard_next_controller.rb b/app/controllers/admin/dashboard_next_controller.rb index 8da41c075d..41360bd7e5 100644 --- a/app/controllers/admin/dashboard_next_controller.rb +++ b/app/controllers/admin/dashboard_next_controller.rb @@ -12,6 +12,7 @@ class Admin::DashboardNextController < Admin::AdminController end def moderation; end + def security; end def general data = AdminDashboardNextGeneralData.fetch_cached_stats diff --git a/app/controllers/admin/email_controller.rb b/app/controllers/admin/email_controller.rb index 59d91fbc0a..a3a5499a1c 100644 --- a/app/controllers/admin/email_controller.rb +++ b/app/controllers/admin/email_controller.rb @@ -89,6 +89,19 @@ class Admin::EmailController < Admin::AdminController render json: MultiJson.dump(html_content: renderer.html, text_content: renderer.text) end + def advanced_test + params.require(:email) + + receiver = Email::Receiver.new(params['email']) + text, elided, format = receiver.select_body + + render json: success_json.merge!( + text: text, + elided: elided, + format: format + ) + end + def send_digest params.require(:last_seen_at) params.require(:username) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b18b1f7b37..ae0ed9de28 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -45,13 +45,12 @@ class Admin::UsersController < Admin::AdminController render_serialized(@user, AdminDetailedUserSerializer, root: false) end - def delete_all_posts - hijack do - user = User.find_by(id: params[:user_id]) - user.delete_all_posts!(guardian) - # staff action logs will have an entry for each post - render body: nil - end + def delete_posts_batch + user = User.find_by(id: params[:user_id]) + deleted_posts = user.delete_posts_in_batches(guardian) + # staff action logs will have an entry for each post + + render json: { posts_deleted: deleted_posts.length } end # DELETE action to delete penalty history for a user @@ -444,7 +443,11 @@ class Admin::UsersController < Admin::AdminController def sync_sso return render body: nil, status: 404 unless SiteSetting.enable_sso - sso = DiscourseSingleSignOn.parse("sso=#{params[:sso]}&sig=#{params[:sig]}") + begin + sso = DiscourseSingleSignOn.parse("sso=#{params[:sso]}&sig=#{params[:sig]}") + rescue DiscourseSingleSignOn::ParseError => e + return render json: failed_json.merge(message: I18n.t("sso.login_error")), status: 422 + end begin user = sso.lookup_or_create_user diff --git a/app/controllers/admin/web_hooks_controller.rb b/app/controllers/admin/web_hooks_controller.rb index 92ec8019c3..1ba3b71a6c 100644 --- a/app/controllers/admin/web_hooks_controller.rb +++ b/app/controllers/admin/web_hooks_controller.rb @@ -111,6 +111,7 @@ class Admin::WebHooksController < Admin::AdminController :wildcard_web_hook, :active, :verify_certificate, web_hook_event_type_ids: [], group_ids: [], + tag_names: [], category_ids: []) end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 9fa1c0d12e..c1bb2dda9e 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -33,7 +33,11 @@ class CategoriesController < ApplicationController ) @category_list.draft = Draft.get(current_user, Draft::NEW_TOPIC, @category_list.draft_sequence) if current_user - @title = "#{I18n.t('js.filters.categories.title')} - #{SiteSetting.title}" unless category_options[:is_homepage] + if category_options[:is_homepage] && SiteSetting.short_site_description.present? + @title = "#{SiteSetting.title} - #{SiteSetting.short_site_description}" + elsif !category_options[:is_homepage] + @title = "#{I18n.t('js.filters.categories.title')} - #{SiteSetting.title}" + end respond_to do |format| format.html do @@ -149,9 +153,8 @@ class CategoriesController < ApplicationController category_params.delete(:position) # properly null the value so the database constraint doesn't catch us - if category_params.has_key?(:email_in) && category_params[:email_in].blank? - category_params[:email_in] = nil - end + category_params[:email_in] = nil if category_params[:email_in]&.blank? + category_params[:minimum_required_tags] = 0 if category_params[:minimum_required_tags]&.blank? old_permissions = cat.permissions_params @@ -277,6 +280,7 @@ class CategoriesController < ApplicationController :auto_close_based_on_last_post, :uploaded_logo_id, :uploaded_background_id, + :uploaded_meta_id, :slug, :allow_badges, :topic_template, diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index 1a66c74003..50456602da 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -7,11 +7,12 @@ class DirectoryItemsController < ApplicationController period = params.require(:period) period_type = DirectoryItem.period_types[period.to_sym] raise Discourse::InvalidAccess.new(:period_type) unless period_type - result = DirectoryItem.where(period_type: period_type).includes(:user) if params[:group] result = result.includes(user: :groups).where(users: { groups: { name: params[:group] } }) + else + result = result.includes(user: :primary_group) end if params[:exclude_usernames] diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 2a191d76a7..19ae0b4f7e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -241,11 +241,13 @@ class GroupsController < ApplicationController .order(username_lower: dir) .limit(limit) .offset(offset) + .includes(:primary_group) owners = users .order(order) .order(username_lower: dir) .where('group_users.owner') + .includes(:primary_group) render json: { members: serialize_data(members, GroupUserSerializer), diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 87d66fd66d..77dba98e5d 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -211,12 +211,21 @@ class InvitesController < ApplicationController def post_process_invite(user) user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message + if user.has_password? - email_token = user.email_tokens.create(email: user.email) - Jobs.enqueue(:critical_user_email, type: :signup, user_id: user.id, email_token: email_token.token) + send_activation_email(user) unless user.active elsif !SiteSetting.enable_sso && SiteSetting.enable_local_logins Jobs.enqueue(:invite_password_instructions_email, username: user.username) end end + def send_activation_email(user) + email_token = user.email_tokens.create!(email: user.email) + + Jobs.enqueue(:critical_user_email, + type: :signup, + user_id: user.id, + email_token: email_token.token + ) + end end diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 6434110e7c..2076d0a9d8 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -107,6 +107,8 @@ class ListController < ApplicationController @title = I18n.t('js.filters.with_topics', filter: filter_title) end @title << " - #{SiteSetting.title}" + elsif (filter.to_s == current_homepage) && SiteSetting.short_site_description.present? + @title = "#{SiteSetting.title} - #{SiteSetting.short_site_description}" end end diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index dacba4dbd7..bbe977b3fd 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -37,7 +37,14 @@ class MetadataController < ApplicationController sizes: file_info[:size], type: file_info[:type] } - ] + ], + share_target: { + action: "/new-topic", + params: { + title: "title", + text: "body" + } + } } manifest[:short_name] = SiteSetting.short_title if SiteSetting.short_title.present? diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index f35ae00e69..e4e0ccf761 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -336,6 +336,7 @@ class PostsController < ApplicationController def destroy_many params.require(:post_ids) + defer_flags = params[:defer_flags] || false posts = Post.where(id: post_ids_including_replies) raise Discourse::InvalidParameters.new(:post_ids) if posts.blank? @@ -344,7 +345,7 @@ class PostsController < ApplicationController posts.each { |p| guardian.ensure_can_delete!(p) } Post.transaction do - posts.each { |p| PostDestroyer.new(current_user, p).destroy } + posts.each { |p| PostDestroyer.new(current_user, p, defer_flags: defer_flags).destroy } end render body: nil diff --git a/app/controllers/queued_posts_controller.rb b/app/controllers/queued_posts_controller.rb index 6b31c14d5a..6b9dadb7cc 100644 --- a/app/controllers/queued_posts_controller.rb +++ b/app/controllers/queued_posts_controller.rb @@ -54,8 +54,8 @@ class QueuedPostsController < ApplicationController def user_deletion_opts base = { - context: I18n.t('queue.delete_reason', performed_by: current_user.username), - delete_posts: true, + context: I18n.t('queue.delete_reason', performed_by: current_user.username), + delete_posts: true, delete_as_spammer: true } diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index fa8eecac5a..195ad63831 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -111,7 +111,17 @@ class SessionController < ApplicationController params.require(:sso) params.require(:sig) - sso = DiscourseSingleSignOn.parse(request.query_string) + begin + sso = DiscourseSingleSignOn.parse(request.query_string) + rescue DiscourseSingleSignOn::ParseError => e + if SiteSetting.verbose_sso_logging + Rails.logger.warn("Verbose SSO log: Signature parse error\n\n#{e.message}\n\n#{sso.diagnostics}") + end + + # Do NOT pass the error text to the client, it would give them the correct signature + return render_sso_error(text: I18n.t("sso.login_error"), status: 422) + end + if !sso.nonce_valid? if SiteSetting.verbose_sso_logging Rails.logger.warn("Verbose SSO log: Nonce has already expired\n\n#{sso.diagnostics}") diff --git a/app/controllers/site_controller.rb b/app/controllers/site_controller.rb index 42d1ffe984..c2f4f3a19c 100644 --- a/app/controllers/site_controller.rb +++ b/app/controllers/site_controller.rb @@ -40,7 +40,7 @@ class SiteController < ApplicationController logo_url: UrlHelper.absolute(SiteSetting.site_logo_url), logo_small_url: UrlHelper.absolute(SiteSetting.site_logo_small_url), apple_touch_icon_url: UrlHelper.absolute(SiteSetting.site_apple_touch_icon_url), - favicon_url: UrlHelper.absolute(SiteSetting.site_favicon_url), + favicon_url: UrlHelper.absolute(SiteSetting.site_favicon_url), title: SiteSetting.title, description: SiteSetting.site_description, header_primary_color: ColorScheme.hex_for_name('header_primary') || '333333', diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index b04b393cc5..64d08d4e1a 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -13,7 +13,7 @@ class UploadsController < ApplicationController # 50 characters ought to be enough for the upload type type = params.require(:type).parameterize(separator: "_")[0..50] - if type == "avatar" && (SiteSetting.sso_overrides_avatar || !SiteSetting.allow_uploaded_avatars) + if type == "avatar" && !me.admin? && (SiteSetting.sso_overrides_avatar || !SiteSetting.allow_uploaded_avatars) return render json: failed_json, status: 422 end diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index c830798867..b3852a6a77 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -6,7 +6,7 @@ class UserBadgesController < ApplicationController badge = fetch_badge_from_params user_badges = badge.user_badges.order('granted_at DESC, id DESC').limit(96) - user_badges = user_badges.includes(:user, :granted_by, badge: :badge_type, post: :topic) + user_badges = user_badges.includes(:user, :granted_by, badge: :badge_type, post: :topic, user: :primary_group) grant_count = nil diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 65310a13c0..60bb2294ef 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -25,8 +25,19 @@ class Users::OmniauthCallbacksController < ApplicationController authenticator = self.class.find_authenticator(params[:provider]) provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] } - if authenticator.can_connect_existing_user? && current_user + if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user + # If we're reconnecting, don't actually try and log the user in @auth_result = authenticator.after_authenticate(auth, existing_account: current_user) + if provider&.full_screen_login || cookies['fsl'] + cookies.delete('fsl') + return redirect_to Discourse.base_uri("/my/preferences/account") + else + @auth_result.authenticated = true + return respond_to do |format| + format.html + format.json { render json: @auth_result.to_client_hash } + end + end else @auth_result = authenticator.after_authenticate(auth) end @@ -64,7 +75,7 @@ class Users::OmniauthCallbacksController < ApplicationController @auth_result.authenticator_name = authenticator.name complete_response_data - if (provider && provider.full_screen_login) || cookies['fsl'] + if provider&.full_screen_login || cookies['fsl'] cookies.delete('fsl') cookies['_bypass_cache'] = true cookies[:authentication_data] = @auth_result.to_client_hash.to_json diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb index 5d9c98a193..176dab9cb9 100644 --- a/app/helpers/user_notifications_helper.rb +++ b/app/helpers/user_notifications_helper.rb @@ -1,4 +1,5 @@ module UserNotificationsHelper + include GlobalPath def indent(text, by = 2) spacer = " " * by @@ -22,11 +23,9 @@ module UserNotificationsHelper logo_url = SiteSetting.site_logo_url if logo_url.blank? || logo_url =~ /\.svg$/i return nil if logo_url.blank? || logo_url =~ /\.svg$/i - if logo_url !~ /http(s)?\:\/\// - logo_url = "#{Discourse.base_url}#{logo_url}" - end - - logo_url + uri = URI.parse(UrlHelper.absolute(upload_cdn_path(logo_url))) + uri.scheme = SiteSetting.scheme if uri.scheme.blank? + uri.to_s end def html_site_link(color) diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 5cd4010abc..643d496a6c 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -28,6 +28,9 @@ module Jobs return if web_hook.category_ids.present? && (!args[:category_id].present? || !web_hook.category_ids.include?(args[:category_id])) + return if web_hook.tag_ids.present? && (args[:tag_ids].blank? || + (web_hook.tag_ids & args[:tag_ids]).blank?) + raise Discourse::InvalidParameters.new(:payload) unless args[:payload].present? args[:payload] = JSON.parse(args[:payload]) end diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index d32e2b822c..4026dc7304 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -10,16 +10,16 @@ module Jobs sidekiq_options retry: false HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( - user_archive: ['topic_title', 'category', 'sub_category', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], - user_list: ['id', 'name', 'username', 'email', 'title', 'created_at', 'last_seen_at', 'last_posted_at', 'last_emailed_at', 'trust_level', 'approved', 'suspended_at', 'suspended_till', 'silenced_till', 'active', 'admin', 'moderator', 'ip_address', 'staged'], - user_stats: ['topics_entered', 'posts_read_count', 'time_read', 'topic_count', 'post_count', 'likes_given', 'likes_received'], - user_profile: ['location', 'website', 'views'], - user_sso: ['external_id', 'external_email', 'external_username', 'external_name', 'external_avatar_url'], - staff_action: ['staff_user', 'action', 'subject', 'created_at', 'details', 'context'], + user_archive: ['topic_title', 'category', 'sub_category', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], + user_list: ['id', 'name', 'username', 'email', 'title', 'created_at', 'last_seen_at', 'last_posted_at', 'last_emailed_at', 'trust_level', 'approved', 'suspended_at', 'suspended_till', 'silenced_till', 'active', 'admin', 'moderator', 'ip_address', 'staged'], + user_stats: ['topics_entered', 'posts_read_count', 'time_read', 'topic_count', 'post_count', 'likes_given', 'likes_received'], + user_profile: ['location', 'website', 'views'], + user_sso: ['external_id', 'external_email', 'external_username', 'external_name', 'external_avatar_url'], + staff_action: ['staff_user', 'action', 'subject', 'created_at', 'details', 'context'], screened_email: ['email', 'action', 'match_count', 'last_match_at', 'created_at', 'ip_address'], - screened_ip: ['ip_address', 'action', 'match_count', 'last_match_at', 'created_at'], - screened_url: ['domain', 'action', 'match_count', 'last_match_at', 'created_at'], - report: ['date', 'value'] + screened_ip: ['ip_address', 'action', 'match_count', 'last_match_at', 'created_at'], + screened_url: ['domain', 'action', 'match_count', 'last_match_at', 'created_at'], + report: ['date', 'value'] ) def execute(args) diff --git a/app/jobs/scheduled/clean_up_associated_accounts.rb b/app/jobs/scheduled/clean_up_associated_accounts.rb new file mode 100644 index 0000000000..2d589cbcb3 --- /dev/null +++ b/app/jobs/scheduled/clean_up_associated_accounts.rb @@ -0,0 +1,12 @@ +module Jobs + + class CleanUpAssociatedAccounts < Jobs::Scheduled + every 1.day + + def execute(args) + UserAssociatedAccount.cleanup! + end + + end + +end diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index e23a520521..13dc588b35 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -48,14 +48,14 @@ module Jobs .where("uploads.created_at < ?", grace_period.hour.ago) .joins(<<~SQL) LEFT JOIN site_settings ss - ON ss.value::integer = uploads.id + ON NULLIF(ss.value, '')::integer = uploads.id AND ss.data_type = #{SiteSettings::TypeSupervisor.types[:upload].to_i} SQL .joins("LEFT JOIN post_uploads pu ON pu.upload_id = uploads.id") .joins("LEFT JOIN users u ON u.uploaded_avatar_id = uploads.id") .joins("LEFT JOIN user_avatars ua ON ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id") .joins("LEFT JOIN user_profiles up ON up.profile_background = uploads.url OR up.card_background = uploads.url") - .joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id") + .joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id OR c.uploaded_meta_id = uploads.id") .joins("LEFT JOIN custom_emojis ce ON ce.upload_id = uploads.id") .joins("LEFT JOIN theme_fields tf ON tf.upload_id = uploads.id") .joins("LEFT JOIN user_exports ue ON ue.upload_id = uploads.id") diff --git a/app/jobs/scheduled/invalidate_inactive_admins.rb b/app/jobs/scheduled/invalidate_inactive_admins.rb new file mode 100644 index 0000000000..12f788af13 --- /dev/null +++ b/app/jobs/scheduled/invalidate_inactive_admins.rb @@ -0,0 +1,29 @@ +module Jobs + + class InvalidateInactiveAdmins < Jobs::Scheduled + every 1.day + + def execute(_) + return if SiteSetting.invalidate_inactive_admin_email_after_days == 0 + + User.human_users + .where(admin: true) + .where(active: true) + .where('last_seen_at < ?', SiteSetting.invalidate_inactive_admin_email_after_days.days.ago) + .each do |user| + + User.transaction do + user.deactivate + user.email_tokens.update_all(confirmed: false, expired: true) + + Discourse.authenticators.each do |authenticator| + if authenticator.can_revoke? && authenticator.description_for_user(user).present? + authenticator.revoke(user) + end + end + end + end + end + end + +end diff --git a/app/jobs/scheduled/periodical_updates.rb b/app/jobs/scheduled/periodical_updates.rb index 73f480494a..2ab9517808 100644 --- a/app/jobs/scheduled/periodical_updates.rb +++ b/app/jobs/scheduled/periodical_updates.rb @@ -28,7 +28,7 @@ module Jobs TopicTimer.ensure_consistency! # Forces rebake of old posts where needed, as long as no system avatars need updating - unless UserAvatar.where("last_gravatar_download_attempt IS NULL").limit(1).first + if !SiteSetting.automatically_download_gravatars || !UserAvatar.where("last_gravatar_download_attempt IS NULL").limit(1).first problems = Post.rebake_old(SiteSetting.rebake_old_posts_count) problems.each do |hash| post_id = hash[:post].id diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index fec948e8be..2d35370225 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -575,7 +575,7 @@ class UserNotifications < ActionMailer::Base if SiteSetting.private_email? message = I18n.t('system_messages.contents_hidden') else - message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : ""); + message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : "") end first_footer_classes = "hilight" diff --git a/app/models/backup_location_site_setting.rb b/app/models/backup_location_site_setting.rb index aef3c89fec..4763c3be04 100644 --- a/app/models/backup_location_site_setting.rb +++ b/app/models/backup_location_site_setting.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_dependency 'enum_site_setting'; +require_dependency 'enum_site_setting' class BackupLocationSiteSetting < EnumSiteSetting LOCAL ||= "local" diff --git a/app/models/category.rb b/app/models/category.rb index 5dcceb5574..af32b64859 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -28,6 +28,7 @@ class Category < ActiveRecord::Base belongs_to :latest_post, class_name: "Post" belongs_to :uploaded_logo, class_name: "Upload" belongs_to :uploaded_background, class_name: "Upload" + belongs_to :uploaded_meta, class_name: "Upload" has_many :topics has_many :category_users @@ -667,6 +668,7 @@ end # sort_ascending :boolean # uploaded_logo_id :integer # uploaded_background_id :integer +# uploaded_meta_id :integer # topic_featured_link_allowed :boolean default(TRUE) # all_topics_wiki :boolean default(FALSE), not null # show_subcategory_list :boolean default(FALSE) diff --git a/app/models/category_list.rb b/app/models/category_list.rb index f59f36c3cd..86631af4c0 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -69,6 +69,7 @@ class CategoryList @categories = Category.includes( :uploaded_background, :uploaded_logo, + :uploaded_meta, :topic_only_relative_url, subcategories: [:topic_only_relative_url] ).secured(@guardian) diff --git a/app/models/category_user.rb b/app/models/category_user.rb index bccd7cbef4..5a87b68fe0 100644 --- a/app/models/category_user.rb +++ b/app/models/category_user.rb @@ -97,7 +97,7 @@ class CategoryUser < ActiveRecord::Base builder.exec( tracking: notification_levels[:tracking], regular: notification_levels[:regular], - auto_track_category: TopicUser.notification_reasons[:auto_track_category] + auto_track_category: TopicUser.notification_reasons[:auto_track_category] ) end @@ -157,7 +157,7 @@ class CategoryUser < ActiveRecord::Base watching: notification_levels[:watching], tracking: notification_levels[:tracking], regular: notification_levels[:regular], - auto_watch_category: TopicUser.notification_reasons[:auto_watch_category] + auto_watch_category: TopicUser.notification_reasons[:auto_watch_category] ) end diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index 434765bef0..526b2a0aaf 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -4,6 +4,8 @@ require_dependency 'distributed_cache' class ColorScheme < ActiveRecord::Base + # rubocop:disable Layout/AlignHash + CUSTOM_SCHEMES = { 'Dark': { "primary" => 'dddddd', @@ -97,8 +99,11 @@ class ColorScheme < ActiveRecord::Base } } + # rubocop:enable Layout/AlignHash + def self.base_color_scheme_colors base_with_hash = {} + base_colors.each do |name, color| base_with_hash[name] = "#{color}" end @@ -110,6 +115,7 @@ class ColorScheme < ActiveRecord::Base CUSTOM_SCHEMES.each do |k, v| list.push(id: k.to_s, colors: v) end + list end @@ -208,6 +214,7 @@ class ColorScheme < ActiveRecord::Base def colors_by_name @colors_by_name ||= self.colors.inject({}) { |sum, c| sum[c.name] = c; sum; } end + def clear_colors_cache @colors_by_name = nil end diff --git a/app/models/facebook_user_info.rb b/app/models/facebook_user_info.rb deleted file mode 100644 index fd1ee51751..0000000000 --- a/app/models/facebook_user_info.rb +++ /dev/null @@ -1,30 +0,0 @@ -class FacebookUserInfo < ActiveRecord::Base - belongs_to :user -end - -# == Schema Information -# -# Table name: facebook_user_infos -# -# id :integer not null, primary key -# user_id :integer not null -# facebook_user_id :bigint(8) not null -# username :string -# first_name :string -# last_name :string -# email :string -# gender :string -# name :string -# link :string -# created_at :datetime not null -# updated_at :datetime not null -# avatar_url :string -# about_me :text -# location :string -# website :text -# -# Indexes -# -# index_facebook_user_infos_on_facebook_user_id (facebook_user_id) UNIQUE -# index_facebook_user_infos_on_user_id (user_id) UNIQUE -# diff --git a/app/models/invite.rb b/app/models/invite.rb index 4ce6045c66..2a7f96876a 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -107,10 +107,19 @@ class Invite < ActiveRecord::Base invite = nil end - invite.update_columns(created_at: Time.zone.now, updated_at: Time.zone.now) if invite + if invite + invite.update_columns( + created_at: Time.zone.now, + updated_at: Time.zone.now, + via_email: invite.via_email && send_email + ) + else + create_args = { + invited_by: invited_by, + email: lower_email, + via_email: send_email + } - if !invite - create_args = { invited_by: invited_by, email: lower_email } create_args[:moderator] = true if opts[:moderator] create_args[:custom_message] = custom_message if custom_message invite = Invite.create!(create_args) @@ -152,9 +161,6 @@ class Invite < ActiveRecord::Base group_ids end - INVITE_ORDER = <<~SQL - SQL - def self.find_all_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page) Invite.where(invited_by_id: inviter.id) .where('invites.email IS NOT NULL') @@ -260,6 +266,7 @@ end # invalidated_at :datetime # moderator :boolean default(FALSE), not null # custom_message :text +# via_email :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 0c1702c364..bbf40ef0d2 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -24,7 +24,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f email: invite.email, username: available_username, name: available_name, - active: true, + active: false, trust_level: SiteSetting.default_invitee_trust_level } @@ -57,6 +57,12 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f end user.save! + + if invite.via_email + user.email_tokens.create!(email: user.email) + user.activate + end + User.find(user.id) end @@ -82,8 +88,9 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f end def mark_invite_redeemed - Invite.where(['id = ? AND redeemed_at IS NULL AND created_at >= ?', - invite.id, SiteSetting.invite_expiry_days.days.ago]).update_all('redeemed_at = CURRENT_TIMESTAMP') + Invite.where('id = ? AND redeemed_at IS NULL AND created_at >= ?', + invite.id, SiteSetting.invite_expiry_days.days.ago) + .update_all('redeemed_at = CURRENT_TIMESTAMP') end def get_invited_user @@ -119,7 +126,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f end def send_welcome_message - if Invite.where(['email = ?', invite.email]).update_all(['user_id = ?', invited_user.id]) == 1 + if Invite.where('email = ?', invite.email).update_all(['user_id = ?', invited_user.id]) == 1 invited_user.send_welcome_message = true end end diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 8442b196b4..8efa750b53 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -435,12 +435,12 @@ class PostAction < ActiveRecord::Base before_create do post_action_type_ids = is_flag? ? PostActionType.notify_flag_types.values : post_action_type_id raise AlreadyActed if PostAction.where(user_id: user_id) - .where(post_id: post_id) - .where(post_action_type_id: post_action_type_ids) - .where(deleted_at: nil) - .where(disagreed_at: nil) - .where(targets_topic: targets_topic) - .exists? + .where(post_id: post_id) + .where(post_action_type_id: post_action_type_ids) + .where(deleted_at: nil) + .where(disagreed_at: nil) + .where(targets_topic: targets_topic) + .exists? end # Returns the flag counts for a post, taking into account that some users diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb index a4067f8c57..92cc688fa9 100644 --- a/app/models/queued_post.rb +++ b/app/models/queued_post.rb @@ -1,6 +1,6 @@ class QueuedPost < ActiveRecord::Base - class InvalidStateTransition < StandardError; end; + class InvalidStateTransition < StandardError; end belongs_to :user belongs_to :topic diff --git a/app/models/report.rb b/app/models/report.rb index 09ad16684e..19b0b70922 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -1276,6 +1276,64 @@ class Report end end + def self.report_staff_logins(report) + report.modes = [:table] + + report.data = [] + + report.labels = [ + { + type: :user, + properties: { + username: :username, + id: :user_id, + avatar: :avatar_template, + }, + title: I18n.t("reports.staff_logins.labels.user") + }, + { + property: :location, + title: I18n.t("reports.staff_logins.labels.location") + }, + { + property: :created_at, + type: :precise_date, + title: I18n.t("reports.staff_logins.labels.login_at") + } + ] + + sql = <<~SQL + SELECT + t1.created_at created_at, + t1.client_ip client_ip, + u.username username, + u.uploaded_avatar_id uploaded_avatar_id, + u.id user_id + FROM ( + SELECT DISTINCT ON (t.client_ip, t.user_id) t.client_ip, t.user_id, t.created_at + FROM user_auth_token_logs t + WHERE t.user_id IN (#{User.admins.pluck(:id).join(',')}) + AND t.created_at >= :start_date + AND t.created_at <= :end_date + ORDER BY t.client_ip, t.user_id, t.created_at DESC + LIMIT #{report.limit || 20} + ) t1 + JOIN users u ON u.id = t1.user_id + ORDER BY created_at DESC + SQL + + DB.query(sql, start_date: report.start_date, end_date: report.end_date).each do |row| + data = {} + data[:avatar_template] = User.avatar_template(row.username, row.uploaded_avatar_id) + data[:user_id] = row.user_id + data[:username] = row.username + data[:location] = DiscourseIpInfo.get(row.client_ip)[:location] + data[:created_at] = row.created_at + + report.data << data + end + end + def self.report_suspicious_logins(report) report.modes = [:table] diff --git a/app/models/site.rb b/app/models/site.rb index e6a0accb3d..8577329d67 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -28,7 +28,7 @@ class Site def categories @categories ||= begin categories = Category - .includes(:uploaded_logo, :uploaded_background) + .includes(:uploaded_logo, :uploaded_background, :uploaded_meta) .secured(@guardian) .joins('LEFT JOIN topics t on t.id = categories.topic_id') .select('categories.*, t.slug topic_slug') diff --git a/app/models/tag_user.rb b/app/models/tag_user.rb index bee895bfe0..b0697fa30d 100644 --- a/app/models/tag_user.rb +++ b/app/models/tag_user.rb @@ -113,12 +113,11 @@ class TagUser < ActiveRecord::Base builder.exec(watching: notification_levels[:watching], tracking: notification_levels[:tracking], regular: notification_levels[:regular], - auto_watch_tag: TopicUser.notification_reasons[:auto_watch_tag]) + auto_watch_tag: TopicUser.notification_reasons[:auto_watch_tag]) end def self.auto_track(opts) - builder = DB.build <<~SQL UPDATE topic_users SET notification_level = :tracking, notifications_reason_id = :auto_track_tag @@ -147,7 +146,7 @@ class TagUser < ActiveRecord::Base builder.exec(tracking: notification_levels[:tracking], regular: notification_levels[:regular], - auto_track_tag: TopicUser.notification_reasons[:auto_track_tag]) + auto_track_tag: TopicUser.notification_reasons[:auto_track_tag]) end end diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 01b5c4c9fc..7b235f281e 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -249,6 +249,7 @@ COMPILED theme.clear_cached_settings! Stylesheet::Manager.clear_theme_cache! if self.name.include?("scss") + CSP::Extension.clear_theme_extensions_cache! if name == 'yaml' # TODO message for mobile vs desktop MessageBus.publish "/header-change/#{theme.id}", self.value if theme && self.name == "header" diff --git a/app/models/theme_setting.rb b/app/models/theme_setting.rb index b851e7e4bd..3616cb8a71 100644 --- a/app/models/theme_setting.rb +++ b/app/models/theme_setting.rb @@ -10,6 +10,7 @@ class ThemeSetting < ActiveRecord::Base theme.remove_from_cache! theme.theme_fields.update_all(value_baked: nil) SvgSprite.expire_cache if self.name.to_s.include?("_icon") + CSP::Extension.clear_theme_extensions_cache! if name.to_s == CSP::Extension::THEME_SETTING end def self.types diff --git a/app/models/topic.rb b/app/models/topic.rb index e3eab679c0..d9ad81b9e5 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -731,7 +731,6 @@ class Topic < ActiveRecord::Base post_type: opts[:post_type] || Post.types[:moderator_action], action_code: opts[:action_code], no_bump: opts[:bump].blank?, - skip_notifications: opts[:skip_notifications], topic_id: self.id, skip_validations: true, custom_fields: opts[:custom_fields]) diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index 229e2eb835..a7be7fa00c 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -111,17 +111,13 @@ SQL def self.extract_from(post) return if post.blank? || post.whisper? - added_urls = [] + current_urls = [] reflected_ids = [] PrettyText .extract_links(post.cooked) .map do |u| - uri = begin - URI.parse(u.url) - rescue URI::Error - end - + uri = UrlHelper.relaxed_parse(u.url) [u, uri] end .reject { |_, p| p.nil? || "mailto".freeze == p.scheme } @@ -130,127 +126,19 @@ SQL TopicLink.transaction do begin - url = link.url - internal = false - topic_id = nil - post_number = nil - - if upload = Upload.get_from_url(url) - internal = Discourse.store.internal? - # Store the same URL that will be used in the cooked version of the post - url = UrlHelper.cook_url(upload.url) - elsif route = Discourse.route_for(parsed) - internal = true - - # We aren't interested in tracking internal links to users - next if route[:controller] == 'users' - - topic_id = route[:topic_id].to_i - post_number = route[:post_number] || 1 - - # Store the canonical URL - topic = Topic.find_by(id: topic_id) - topic_id = nil unless topic - - if topic.present? - url = "#{Discourse.base_url_no_prefix}#{topic.relative_url}" - url << "/#{post_number}" if post_number.to_i > 1 - end - end - - # Skip linking to ourselves - next if topic_id == post.topic_id - - reflected_post = nil - if post_number && topic_id - reflected_post = Post.find_by(topic_id: topic_id, post_number: post_number.to_i) - end - - url = url[0...TopicLink.max_url_length] - next if parsed && parsed.host && parsed.host.length > TopicLink.max_domain_length - - added_urls << url - - unless TopicLink.exists?(topic_id: post.topic_id, post_id: post.id, url: url) - file_extension = File.extname(parsed.path)[1..10].downcase unless parsed.path.nil? || File.extname(parsed.path).empty? - begin - TopicLink.create!(post_id: post.id, - user_id: post.user_id, - topic_id: post.topic_id, - url: url, - domain: parsed.host || Discourse.current_hostname, - internal: internal, - link_topic_id: topic_id, - link_post_id: reflected_post.try(:id), - quote: link.is_quote, - extension: file_extension) - rescue ActiveRecord::RecordNotUnique - # it's fine - end - end - - # Create the reflection if we can - if topic_id.present? - topic = Topic.find_by(id: topic_id) - - if topic && post.topic && topic.archetype != 'private_message' && post.topic.archetype != 'private_message' && post.topic.visible? - prefix = Discourse.base_url_no_prefix - reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" - tl = TopicLink.find_by(topic_id: topic_id, - post_id: reflected_post.try(:id), - url: reflected_url) - - unless tl - tl = TopicLink.create(user_id: post.user_id, - topic_id: topic_id, - post_id: reflected_post.try(:id), - url: reflected_url, - domain: Discourse.current_hostname, - reflection: true, - internal: true, - link_topic_id: post.topic_id, - link_post_id: post.id) - - end - - reflected_ids << tl.id if tl.persisted? - end - end - + url, reflected_id = self.ensure_entry_for(post, link, parsed) + current_urls << url unless url.nil? + reflected_ids << reflected_id unless reflected_id.nil? rescue URI::Error # if the URI is invalid, don't store it. rescue ActionController::RoutingError # If we can't find the route, no big deal end end - - # Remove links that aren't there anymore - if added_urls.present? - TopicLink.where( - "(url not in (:urls)) AND (post_id = :post_id AND NOT reflection)", - urls: added_urls, post_id: post.id - ).delete_all - - reflected_ids.compact! - if reflected_ids.present? - TopicLink.where( - "(id not in (:reflected_ids)) AND (link_post_id = :post_id AND reflection)", - reflected_ids: reflected_ids, post_id: post.id - ).delete_all - else - TopicLink - .where("link_post_id = :post_id AND reflection", post_id: post.id) - .delete_all - end - else - TopicLink - .where( - "(post_id = :post_id AND NOT reflection) OR (link_post_id = :post_id AND reflection)", - post_id: post.id - ) - .delete_all - end end + + self.cleanup_entries(post, current_urls, reflected_ids) + end # Crawl a link's title after it's saved @@ -277,6 +165,128 @@ SQL lookup end + + private + + def self.ensure_entry_for(post, link, parsed) + url = link.url + internal = false + topic_id = nil + post_number = nil + + if upload = Upload.get_from_url(url) + internal = Discourse.store.internal? + # Store the same URL that will be used in the cooked version of the post + url = UrlHelper.cook_url(upload.url) + elsif route = Discourse.route_for(parsed) + internal = true + + # We aren't interested in tracking internal links to users + return nil if route[:controller] == 'users' + + topic_id = route[:topic_id].to_i + post_number = route[:post_number] || 1 + + # Store the canonical URL + topic = Topic.find_by(id: topic_id) + topic_id = nil unless topic + + if topic.present? + url = "#{Discourse.base_url_no_prefix}#{topic.relative_url}" + url << "/#{post_number}" if post_number.to_i > 1 + end + end + + # Skip linking to ourselves + return nil if topic_id == post.topic_id + + reflected_post = nil + if post_number && topic_id + reflected_post = Post.find_by(topic_id: topic_id, post_number: post_number.to_i) + end + + url = url[0...TopicLink.max_url_length] + return nil if parsed && parsed.host && parsed.host.length > TopicLink.max_domain_length + + unless TopicLink.exists?(topic_id: post.topic_id, post_id: post.id, url: url) + file_extension = File.extname(parsed.path)[1..10].downcase unless parsed.path.nil? || File.extname(parsed.path).empty? + begin + TopicLink.create!(post_id: post.id, + user_id: post.user_id, + topic_id: post.topic_id, + url: url, + domain: parsed.host || Discourse.current_hostname, + internal: internal, + link_topic_id: topic_id, + link_post_id: reflected_post.try(:id), + quote: link.is_quote, + extension: file_extension) + rescue ActiveRecord::RecordNotUnique + # it's fine + end + end + + reflected_id = nil + + # Create the reflection if we can + if topic_id.present? + topic = Topic.find_by(id: topic_id) + + if topic && post.topic && topic.archetype != 'private_message' && post.topic.archetype != 'private_message' && post.topic.visible? + prefix = Discourse.base_url_no_prefix + reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" + tl = TopicLink.find_by(topic_id: topic_id, + post_id: reflected_post.try(:id), + url: reflected_url) + + unless tl + tl = TopicLink.create(user_id: post.user_id, + topic_id: topic_id, + post_id: reflected_post.try(:id), + url: reflected_url, + domain: Discourse.current_hostname, + reflection: true, + internal: true, + link_topic_id: post.topic_id, + link_post_id: post.id) + + end + + reflected_id = tl.id if tl.persisted? + end + end + + [url, reflected_id] + end + + def self.cleanup_entries(post, current_urls, current_reflected_ids) + # Remove links that aren't there anymore + if current_urls.present? + TopicLink.where( + "(url not in (:urls)) AND (post_id = :post_id AND NOT reflection)", + urls: current_urls, post_id: post.id + ).delete_all + + current_reflected_ids.compact! + if current_reflected_ids.present? + TopicLink.where( + "(id not in (:reflected_ids)) AND (link_post_id = :post_id AND reflection)", + reflected_ids: current_reflected_ids, post_id: post.id + ).delete_all + else + TopicLink + .where("link_post_id = :post_id AND reflection", post_id: post.id) + .delete_all + end + else + TopicLink + .where( + "(post_id = :post_id AND NOT reflection) OR (link_post_id = :post_id AND reflection)", + post_id: post.id + ) + .delete_all + end + end end # == Schema Information diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb index 7ae52e6b09..6caaff82d4 100644 --- a/app/models/topic_link_click.rb +++ b/app/models/topic_link_click.rb @@ -15,11 +15,7 @@ class TopicLinkClick < ActiveRecord::Base url = args[:url][0...TopicLink.max_url_length] return nil if url.blank? - uri = begin - URI.parse(url) - rescue URI::Error - end - + uri = UrlHelper.relaxed_parse(url) urls = Set.new urls << url if url =~ /^http/ diff --git a/app/models/twitter_user_info.rb b/app/models/twitter_user_info.rb deleted file mode 100644 index 07302bc777..0000000000 --- a/app/models/twitter_user_info.rb +++ /dev/null @@ -1,21 +0,0 @@ -class TwitterUserInfo < ActiveRecord::Base - belongs_to :user -end - -# == Schema Information -# -# Table name: twitter_user_infos -# -# id :integer not null, primary key -# user_id :integer not null -# screen_name :string not null -# twitter_user_id :bigint(8) not null -# created_at :datetime not null -# updated_at :datetime not null -# email :string(1000) -# -# Indexes -# -# index_twitter_user_infos_on_twitter_user_id (twitter_user_id) UNIQUE -# index_twitter_user_infos_on_user_id (user_id) UNIQUE -# diff --git a/app/models/upload.rb b/app/models/upload.rb index bf3d415515..350537952c 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -127,8 +127,12 @@ class Upload < ActiveRecord::Base Discourse.store.download(self).path end - self.width, self.height = size = FastImage.new(path).size - self.thumbnail_width, self.thumbnail_height = ImageSizer.resize(*size) + begin + self.width, self.height = size = FastImage.new(path, raise_on_failure: true).size + self.thumbnail_width, self.thumbnail_height = ImageSizer.resize(*size) + rescue => e + Discourse.warn_exception(e, message: "Error getting image dimensions") + end nil end diff --git a/app/models/user.rb b/app/models/user.rb index 97415261ca..8ce55b3e06 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -65,8 +65,7 @@ class User < ActiveRecord::Base has_one :user_option, dependent: :destroy has_one :user_avatar, dependent: :destroy - has_one :facebook_user_info, dependent: :destroy - has_one :twitter_user_info, dependent: :destroy + has_many :user_associated_accounts, dependent: :destroy has_one :github_user_info, dependent: :destroy has_one :google_user_info, dependent: :destroy has_many :oauth2_user_infos, dependent: :destroy @@ -478,7 +477,7 @@ class User < ActiveRecord::Base DB.query_single(sql, user_id: id, seen_notification_id: seen_notification_id, - pm: Notification.types[:private_message], + pm: Notification.types[:private_message], limit: User.max_unread_notifications )[0].to_i end @@ -780,12 +779,12 @@ class User < ActiveRecord::Base (since_reply.count >= SiteSetting.newuser_max_replies_per_topic) end - def delete_all_posts!(guardian) + def delete_posts_in_batches(guardian, batch_size = 20) raise Discourse::InvalidAccess unless guardian.can_delete_all_posts? self QueuedPost.where(user_id: id).delete_all - posts.order("post_number desc").each do |p| + posts.order("post_number desc").limit(batch_size).each do |p| PostDestroyer.new(guardian.user, p).destroy end end diff --git a/app/models/user_associated_account.rb b/app/models/user_associated_account.rb new file mode 100644 index 0000000000..d21078153c --- /dev/null +++ b/app/models/user_associated_account.rb @@ -0,0 +1,30 @@ +class UserAssociatedAccount < ActiveRecord::Base + belongs_to :user + + def self.cleanup! + # This happens when a user starts the registration flow, but doesn't complete it + # Keeping the rows doesn't cause any technical issue, but we shouldn't store PII unless it's attached to a user + self.where("user_id IS NULL AND updated_at < ?", 1.day.ago).delete_all + end +end + +# == Schema Information +# +# Table name: user_associated_accounts +# +# id :bigint(8) not null, primary key +# provider_name :string not null +# provider_uid :string not null +# user_id :integer +# last_used :datetime not null +# info :jsonb not null +# credentials :jsonb not null +# extra :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# associated_accounts_provider_uid (provider_name,provider_uid) UNIQUE +# associated_accounts_provider_user (provider_name,user_id) UNIQUE +# diff --git a/app/models/user_auth_token.rb b/app/models/user_auth_token.rb index ffe26e07cd..0dcf75cbd3 100644 --- a/app/models/user_auth_token.rb +++ b/app/models/user_auth_token.rb @@ -36,7 +36,7 @@ class UserAuthToken < ActiveRecord::Base def self.login_location(ip) ipinfo = DiscourseIpInfo.get(ip) - ipinfo['latitude'] && ipinfo['longitude'] ? [ipinfo['latitude'], ipinfo['longitude']] : nil + ipinfo[:latitude] && ipinfo[:longitude] ? [ipinfo[:latitude], ipinfo[:longitude]] : nil end def self.distance(loc1, loc2) diff --git a/app/models/user_history.rb b/app/models/user_history.rb index ae1851c0eb..745188bc7b 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -28,7 +28,7 @@ class UserHistory < ActiveRecord::Base notified_about_dominating_topic: 9, suspend_user: 10, unsuspend_user: 11, - facebook_no_email: 12, + facebook_no_email: 12, # not used anymore grant_badge: 13, revoke_badge: 14, auto_trust_level_change: 15, diff --git a/app/models/user_stat.rb b/app/models/user_stat.rb index 65f48d62dc..c4806af20b 100644 --- a/app/models/user_stat.rb +++ b/app/models/user_stat.rb @@ -101,7 +101,7 @@ class UserStat < ActiveRecord::Base end def self.cache_last_seen(id, val) - $redis.set(last_seen_key(id), val) + $redis.setex(last_seen_key(id), MAX_TIME_READ_DIFF, val) end protected diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 518b076abc..b2fe649156 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -2,6 +2,7 @@ class WebHook < ActiveRecord::Base has_and_belongs_to_many :web_hook_event_types has_and_belongs_to_many :groups has_and_belongs_to_many :categories + has_and_belongs_to_many :tags has_many :web_hook_events, dependent: :destroy @@ -15,6 +16,10 @@ class WebHook < ActiveRecord::Base before_save :strip_url + def tag_names=(tag_names_arg) + DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true) + end + def self.content_types @content_types ||= Enum.new('application/json' => 1, 'application/x-www-form-urlencoded' => 2) @@ -61,25 +66,27 @@ class WebHook < ActiveRecord::Base end def self.enqueue_topic_hooks(event, topic) - if active_web_hooks('topic').exists? + if active_web_hooks('topic').exists? && topic.present? topic_view = TopicView.new(topic.id, Discourse.system_user) payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer) WebHook.enqueue_hooks(:topic, event, id: topic.id, - category_id: topic&.category_id, + category_id: topic.category_id, + tag_ids: topic.tags.pluck(:id), payload: payload ) end end def self.enqueue_post_hooks(event, post) - if active_web_hooks('post').exists? + if active_web_hooks('post').exists? && post.present? payload = WebHook.generate_payload(:post, post) WebHook.enqueue_hooks(:post, event, id: post.id, - category_id: post&.topic&.category_id, + category_id: post.topic&.category_id, + tag_ids: post.topic&.tags&.pluck(:id), payload: payload ) end diff --git a/app/serializers/admin_web_hook_serializer.rb b/app/serializers/admin_web_hook_serializer.rb index ff74ae3f9d..711674b732 100644 --- a/app/serializers/admin_web_hook_serializer.rb +++ b/app/serializers/admin_web_hook_serializer.rb @@ -10,6 +10,7 @@ class AdminWebHookSerializer < ApplicationSerializer :web_hook_event_types has_many :categories, serializer: BasicCategorySerializer, embed: :ids, include: false + has_many :tags, key: :tag_names, serializer: TagSerializer, embed: :ids, embed_key: :name, include: false has_many :groups, serializer: BasicGroupSerializer, embed: :ids, include: false def web_hook_event_types diff --git a/app/serializers/basic_category_serializer.rb b/app/serializers/basic_category_serializer.rb index 6be2df27ff..2074314a3e 100644 --- a/app/serializers/basic_category_serializer.rb +++ b/app/serializers/basic_category_serializer.rb @@ -30,6 +30,7 @@ class BasicCategorySerializer < ApplicationSerializer has_one :uploaded_logo, embed: :object, serializer: CategoryUploadSerializer has_one :uploaded_background, embed: :object, serializer: CategoryUploadSerializer + has_one :uploaded_meta, embed: :object, serializer: CategoryUploadSerializer def include_parent_category_id? parent_category_id diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index 6ac6f23b49..fdeb1eec1f 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -42,6 +42,14 @@ class BasicGroupSerializer < ApplicationSerializer staff? end + def include_automatic_membership_email_domains? + scope.is_admin? + end + + def include_automatic_membership_retroactive? + scope.is_admin? + end + def include_has_messages? staff? || scope.can_see_group_messages?(object) end diff --git a/app/serializers/concerns/user_primary_group_mixin.rb b/app/serializers/concerns/user_primary_group_mixin.rb new file mode 100644 index 0000000000..43f7d90e25 --- /dev/null +++ b/app/serializers/concerns/user_primary_group_mixin.rb @@ -0,0 +1,42 @@ +module UserPrimaryGroupMixin + + def self.included(klass) + klass.attributes :primary_group_name, + :primary_group_flair_url, + :primary_group_flair_bg_color, + :primary_group_flair_color + end + + def primary_group_name + object&.primary_group&.name + end + + def include_primary_group_name? + object&.primary_group.present? + end + + def primary_group_flair_url + object&.primary_group&.flair_url + end + + def include_primary_group_flair_url? + object&.primary_group&.flair_url.present? + end + + def primary_group_flair_bg_color + object&.primary_group&.flair_bg_color + end + + def include_primary_group_flair_bg_color? + object&.primary_group&.flair_bg_color.present? + end + + def primary_group_flair_color + object&.primary_group&.flair_color + end + + def include_primary_group_flair_color? + object&.primary_group&.flair_color.present? + end + +end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index d7c683ddb8..27eb3500a9 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -45,6 +45,8 @@ class CurrentUserSerializer < BasicUserSerializer :top_category_ids, :hide_profile_and_presence + has_many :groups, embed: :object, serializer: BasicGroupSerializer + def link_posting_access scope.link_posting_access end diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index 753d7a8ad7..a81430b22c 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -1,9 +1,13 @@ class DirectoryItemSerializer < ApplicationSerializer + class UserSerializer < UserNameSerializer + include UserPrimaryGroupMixin + end + attributes :id, :time_read - has_one :user, embed: :objects, serializer: UserNameSerializer + has_one :user, embed: :objects, serializer: UserSerializer attributes *DirectoryItem.headings def id diff --git a/app/serializers/group_user_serializer.rb b/app/serializers/group_user_serializer.rb index c254088381..43f7fbc062 100644 --- a/app/serializers/group_user_serializer.rb +++ b/app/serializers/group_user_serializer.rb @@ -1,7 +1,14 @@ class GroupUserSerializer < BasicUserSerializer - attributes :name, :title, :last_posted_at, :last_seen_at, :added_at + include UserPrimaryGroupMixin + + attributes :name, + :title, + :last_posted_at, + :last_seen_at, + :added_at def include_added_at object.respond_to? :added_at end + end diff --git a/app/serializers/user_badge_serializer.rb b/app/serializers/user_badge_serializer.rb index 9a5e6d9ebd..3c73951748 100644 --- a/app/serializers/user_badge_serializer.rb +++ b/app/serializers/user_badge_serializer.rb @@ -1,7 +1,11 @@ class UserBadgeSerializer < ApplicationSerializer class UserSerializer < BasicUserSerializer - attributes :name, :moderator, :admin + include UserPrimaryGroupMixin + + attributes :name, + :moderator, + :admin end attributes :id, :granted_at, :count, :post_id, :post_number diff --git a/app/serializers/wizard_step_serializer.rb b/app/serializers/wizard_step_serializer.rb index 458bee2c85..bb792a2f7e 100644 --- a/app/serializers/wizard_step_serializer.rb +++ b/app/serializers/wizard_step_serializer.rb @@ -39,9 +39,8 @@ class WizardStepSerializer < ApplicationSerializer end def description - return translate("disabled") if object.disabled - - translate("description", base_path: Discourse.base_path) + key = object.disabled ? "disabled" : "description" + translate(key, object.description_vars) end def include_description? diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 8cda53c526..a702efde7b 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -43,7 +43,10 @@ class PostAlerter end def notify_about_reply?(post) - post.post_type == Post.types[:regular] || post.post_type == Post.types[:whisper] + # small actions can be whispers in this case they will have an action code + # we never want to notify on this + post.post_type == Post.types[:regular] || + (post.post_type == Post.types[:whisper] && post.action_code.nil?) end def after_save_post(post, new_record = false) @@ -289,9 +292,9 @@ class PostAlerter # apply muting here return if notifier_id && MutedUser.where(user_id: user.id, muted_user_id: notifier_id) - .joins(:muted_user) - .where('NOT admin AND NOT moderator') - .exists? + .joins(:muted_user) + .where('NOT admin AND NOT moderator') + .exists? # skip if muted on the topic return if TopicUser.where( @@ -396,27 +399,30 @@ class PostAlerter ) if created.id && !existing_notification && NOTIFIABLE_TYPES.include?(type) && !user.suspended? - post_url = original_post.url - if post_url - payload = { - notification_type: type, - post_number: original_post.post_number, - topic_title: original_post.topic.title, - topic_id: original_post.topic.id, - excerpt: original_post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true), - username: original_username, - post_url: post_url - } - - MessageBus.publish("/notification-alert/#{user.id}", payload, user_ids: [user.id]) - push_notification(user, payload) - DiscourseEvent.trigger(:post_notification_alert, user, payload) - end + create_notification_alert(user: user, post: original_post, notification_type: type, username: original_username) end created.id ? created : nil end + def create_notification_alert(user:, post:, notification_type:, excerpt: nil, username: nil) + if post_url = post.url + payload = { + notification_type: notification_type, + post_number: post.post_number, + topic_title: post.topic.title, + topic_id: post.topic.id, + excerpt: excerpt || post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true), + username: username || post.username, + post_url: post_url + } + + MessageBus.publish("/notification-alert/#{user.id}", payload, user_ids: [user.id]) + push_notification(user, payload) + DiscourseEvent.trigger(:post_notification_alert, user, payload) + end + end + def contains_email_address?(addresses, user) return false if addresses.blank? addresses.split(";").include?(user.email) diff --git a/app/services/push_notification_pusher.rb b/app/services/push_notification_pusher.rb index 2e39eb9cac..d397e13039 100644 --- a/app/services/push_notification_pusher.rb +++ b/app/services/push_notification_pusher.rb @@ -49,7 +49,7 @@ class PushNotificationPusher title: I18n.t("discourse_push_notifications.popup.confirm_title", site_title: SiteSetting.title), body: I18n.t("discourse_push_notifications.popup.confirm_body"), - icon: ActionController::Base.helpers.image_url("push-notifications/check.png"), + icon: ActionController::Base.helpers.image_url("push-notifications/check.png"), badge: get_badge, tag: "#{Discourse.current_hostname}-subscription" } diff --git a/app/services/user_anonymizer.rb b/app/services/user_anonymizer.rb index daaa976301..d87c7e6da7 100644 --- a/app/services/user_anonymizer.rb +++ b/app/services/user_anonymizer.rb @@ -53,12 +53,11 @@ class UserAnonymizer end @user.user_avatar.try(:destroy) - @user.twitter_user_info.try(:destroy) @user.google_user_info.try(:destroy) @user.github_user_info.try(:destroy) - @user.facebook_user_info.try(:destroy) @user.single_sign_on_record.try(:destroy) @user.oauth2_user_infos.try(:destroy_all) + @user.user_associated_accounts.try(:destroy_all) @user.instagram_user_info.try(:destroy) @user.user_open_ids.find_each { |x| x.destroy } @user.api_key.try(:destroy) diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 755844f0ff..558f08953c 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -123,9 +123,9 @@ class UserUpdater update_muted_users(attributes[:muted_usernames]) end + name_changed = user.name_changed? if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) && - (attributes[:name].present? && old_user_name.casecmp(attributes.fetch(:name)) != 0) || - (attributes[:name].blank? && old_user_name.present?) + (name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0) StaffActionLogger.new(@actor).log_name_change( user.id, diff --git a/bin/rake b/bin/rake index 17240489f6..febc865cbd 100755 --- a/bin/rake +++ b/bin/rake @@ -1,4 +1,12 @@ #!/usr/bin/env ruby + +if ENV['RAILS_ENV'] == 'test' && ENV['LOAD_PLUGINS'].nil? + if ARGV.include?('db:migrate') + STDERR.puts "You are attempting to run migrations in your test environment and are not loading plugins, setting LOAD_PLUGINS to 1" + ENV['LOAD_PLUGINS'] = '1' + end +end + require_relative '../config/boot' require 'rake' Rake.application.run diff --git a/config/application.rb b/config/application.rb index 692011edb9..89c161bbfe 100644 --- a/config/application.rb +++ b/config/application.rb @@ -204,7 +204,7 @@ module Discourse config.middleware.insert_after Rack::MethodOverride, Middleware::EnforceHostname end - require 'content_security_policy' + require 'content_security_policy/middleware' config.middleware.swap ActionDispatch::ContentSecurityPolicy::Middleware, ContentSecurityPolicy::Middleware require 'middleware/discourse_public_exceptions' diff --git a/config/boot.rb b/config/boot.rb index ba9f61acee..97806eac15 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -19,12 +19,12 @@ if ENV['RAILS_ENV'] != 'production' && ENV['RAILS_ENV'] != 'profile' if defined? Bootsnap Bootsnap.setup( - cache_dir: 'tmp/cache', # Path to your cache - load_path_cache: true, # Should we optimize the LOAD_PATH with a cache? - autoload_paths_cache: true, # Should we optimize ActiveSupport autoloads with cache? - disable_trace: false, # Sets `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }` - compile_cache_iseq: true, # Should compile Ruby code into ISeq cache? - compile_cache_yaml: false # Skip YAML cache for now, cause we were seeing issues with it + cache_dir: 'tmp/cache', # Path to your cache + load_path_cache: true, # Should we optimize the LOAD_PATH with a cache? + autoload_paths_cache: true, # Should we optimize ActiveSupport autoloads with cache? + disable_trace: false, # Sets `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }` + compile_cache_iseq: true, # Should compile Ruby code into ISeq cache? + compile_cache_yaml: false # Skip YAML cache for now, cause we were seeing issues with it ) end end diff --git a/config/environments/production.rb b/config/environments/production.rb index c6b0d6eb84..c12ed4eaa8 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -24,12 +24,12 @@ Discourse::Application.configure do if GlobalSetting.smtp_address settings = { - address: GlobalSetting.smtp_address, - port: GlobalSetting.smtp_port, - domain: GlobalSetting.smtp_domain, - user_name: GlobalSetting.smtp_user_name, - password: GlobalSetting.smtp_password, - authentication: GlobalSetting.smtp_authentication, + address: GlobalSetting.smtp_address, + port: GlobalSetting.smtp_port, + domain: GlobalSetting.smtp_domain, + user_name: GlobalSetting.smtp_user_name, + password: GlobalSetting.smtp_password, + authentication: GlobalSetting.smtp_authentication, enable_starttls_auto: GlobalSetting.smtp_enable_start_tls } diff --git a/config/initializers/004-rails_multisite.rb b/config/initializers/004-rails_multisite.rb new file mode 100644 index 0000000000..21543492ea --- /dev/null +++ b/config/initializers/004-rails_multisite.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module RailsMultisite + class ConnectionManagement + def self.safe_each_connection + self.each_connection do |db| + begin + yield(db) if block_given? + rescue => e + STDERR.puts "URGENT: Failed to initialize site #{db}: "\ + "#{e.class} #{e.message}\n#{e.backtrace.join("\n")}" + + # the show must go on, don't stop startup if multisite fails + end + end + end + end + + class DiscoursePatches + def self.config + { + db_lookup: lambda do |env| + env["PATH_INFO"] == "/srv/status" ? "default" : nil + end + } + end + end +end + +if Rails.configuration.multisite + Rails.configuration.middleware.swap( + RailsMultisite::Middleware, + RailsMultisite::Middleware, + RailsMultisite::DiscoursePatches.config + ) +end diff --git a/config/initializers/005-site_settings.rb b/config/initializers/005-site_settings.rb index 9209ff55e4..033db48f61 100644 --- a/config/initializers/005-site_settings.rb +++ b/config/initializers/005-site_settings.rb @@ -4,7 +4,7 @@ Discourse.git_version reload_settings = lambda { - RailsMultisite::ConnectionManagement.each_connection do + RailsMultisite::ConnectionManagement.safe_each_connection do begin SiteSetting.refresh! @@ -13,10 +13,6 @@ reload_settings = lambda { end rescue ActiveRecord::StatementInvalid # This will happen when migrating a new database - rescue => e - STDERR.puts "URGENT: Failed to initialize site #{RailsMultisite::ConnectionManagement.current_db}:"\ - "#{e.message}\n#{e.backtrace.join("\n")}" - # the show must go on, don't stop startup if multisite fails end end } diff --git a/config/initializers/006-mini_profiler.rb b/config/initializers/006-mini_profiler.rb index 4af6edb08f..7620cb3d8f 100644 --- a/config/initializers/006-mini_profiler.rb +++ b/config/initializers/006-mini_profiler.rb @@ -13,8 +13,7 @@ if Rails.configuration.respond_to?(:load_mini_profiler) && Rails.configuration.l Rack::MiniProfilerRails.initialize!(Rails.application) end -if defined?(Rack::MiniProfiler) - +if defined?(Rack::MiniProfiler) && defined?(Rack::MiniProfiler::Config) # note, we may want to add some extra security here that disables mini profiler in a multi hosted env unless user global admin # raw_connection means results are not namespaced # diff --git a/config/initializers/014-rails_multisite.rb b/config/initializers/014-rails_multisite.rb deleted file mode 100644 index 630b07ff80..0000000000 --- a/config/initializers/014-rails_multisite.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class RailsMultisite::DiscoursePatches - def self.config - { - db_lookup: lambda do |env| - env["PATH_INFO"] == "/srv/status" ? "default" : nil - end - } - end -end - -if Rails.configuration.multisite - Rails.configuration.middleware.swap( - RailsMultisite::Middleware, - RailsMultisite::Middleware, - RailsMultisite::DiscoursePatches.config - ) -end diff --git a/config/initializers/100-logster.rb b/config/initializers/100-logster.rb index 833e1c60b3..1ef50d2360 100644 --- a/config/initializers/100-logster.rb +++ b/config/initializers/100-logster.rb @@ -50,7 +50,15 @@ if Rails.env.production? # we handle this cleanly in the message bus middleware # no point logging to logster - /RateLimiter::LimitExceeded.*/m + /RateLimiter::LimitExceeded.*/m, + + # see https://github.com/rails/rails/issues/34599 + # Poll defines an enum with the value `open` ActiveRecord then attempts + # AR then warns cause #open is being redefined, it is already defined + # privately in Kernel per: http://ruby-doc.org/core-2.5.3/Kernel.html#method-i-open + # Once the rails issue is fixed we can stop this error suppression and stop defining + # scopes for the enums + /^Creating scope :open\. Overwriting existing method Poll\.open\./, ] end @@ -86,7 +94,14 @@ RailsMultisite::ConnectionManagement.each_connection do if (error_rate_per_minute || 0) > 0 store.register_rate_limit_per_minute(severities, error_rate_per_minute) do |rate| - MessageBus.publish("/logs_error_rate_exceeded", rate: rate, duration: 'minute', publish_at: Time.current.to_i) + MessageBus.publish("/logs_error_rate_exceeded", + { + rate: rate, + duration: 'minute', + publish_at: Time.current.to_i + }, + group_ids: [Group::AUTO_GROUPS[:admins]] + ) end end @@ -94,7 +109,14 @@ RailsMultisite::ConnectionManagement.each_connection do if (error_rate_per_hour || 0) > 0 store.register_rate_limit_per_hour(severities, error_rate_per_hour) do |rate| - MessageBus.publish("/logs_error_rate_exceeded", rate: rate, duration: 'hour', publish_at: Time.current.to_i) + MessageBus.publish("/logs_error_rate_exceeded", + { + rate: rate, + duration: 'hour', + publish_at: Time.current.to_i, + }, + group_ids: [Group::AUTO_GROUPS[:admins]] + ) end end end diff --git a/config/initializers/100-sidekiq.rb b/config/initializers/100-sidekiq.rb index 1e2ff8dddf..efc76bed98 100644 --- a/config/initializers/100-sidekiq.rb +++ b/config/initializers/100-sidekiq.rb @@ -46,7 +46,7 @@ if Sidekiq.server? Scheduler::Defer.async = false # warm up AR - RailsMultisite::ConnectionManagement.each_connection do + RailsMultisite::ConnectionManagement.safe_each_connection do (ActiveRecord::Base.connection.tables - %w[schema_migrations]).each do |table| table.classify.constantize.first rescue nil end diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index e351e19457..12ce0a5a60 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -479,6 +479,7 @@ de: description: "Du erhältst keine Benachrichtigungen über neue Themen in dieser Gruppe." flair_url: "Avatar-Hintergrund" flair_url_placeholder: "(Optional) Bild-URL oder Font Awesome-Klasse" + flair_url_description: "Verwende quadratische Bilder, die nicht kleiner als 20x20 Pixel sind oder FontAwesome-Icons (akzeptierte Formate: \"fa-icon\", \"far fa-icon\" oder \"fab fa-icon\")." flair_bg_color: "Avatar-Hintergrundfarbe" flair_bg_color_placeholder: "(Optional) Hex-Farbwert" flair_color: "Avatar-Hintergrundfarbe" @@ -520,6 +521,12 @@ de: topic_sentence: one: "1 Thema" other: "%{count} Themen" + topic_stat_sentence_week: + one: "%{count} neues Thema in der letzten Woche." + other: "%{count} neue Themen in der letzten Woche." + topic_stat_sentence_month: + one: "%{count} neues Thema im letzten Monat." + other: "%{count} neue Themen im letzten Monat." n_more: "Kategorien (%{count} weitere) ..." ip_lookup: title: IP-Adressen-Abfrage @@ -1244,6 +1251,7 @@ de: title_too_long: "Titel darf maximal {{max}} Zeichen lang sein" post_missing: "Beitrag darf nicht leer sein" post_length: "Beitrag muss mindestens {{min}} Zeichen lang sein" + try_like: "Hast du schon die {{heart}}-Schaltfläche ausprobiert?" category_missing: "Du musst eine Kategorie auswählen" tags_missing: "Du musst mindestens %{count} Schlagwörter wählen." save_edit: "Speichern" @@ -2050,6 +2058,7 @@ de: revert: "Diese Überarbeitung wiederherstellen" edit_wiki: "Bearbeite Wiki" edit_post: "Bearbeite Beitrag" + comparing_previous_to_current_out_of_total: "{{previous}} {{icon}} {{current}} / {{total}}" displays: inline: title: "Zeige die Änderungen inline an" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b06270555d..548fd10194 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -296,6 +296,10 @@ en: new_topic: "New topic draft" new_private_message: "New private message draft" topic_reply: "Draft reply" + abandon: + confirm: "You already opened another draft in this topic. Are you sure you want to abandon it?" + yes_value: "Yes, abandon" + no_value: "No, keep" topic_count_latest: one: "See {{count}} new or updated topic" @@ -1437,6 +1441,7 @@ en: toggle_direction: "Toggle Direction" help: "Markdown Editing Help" collapse: "minimize the composer panel" + open: "open the composer panel" abandon: "close composer and discard draft" enter_fullscreen: "enter fullscreen composer" exit_fullscreen: "exit fullscreen composer" @@ -2849,6 +2854,7 @@ en: all_reports: "All reports" general_tab: "General" moderation_tab: "Moderation" + security_tab: "Security" disabled: Disabled timeout_error: Sorry, query is taking too long, please pick a shorter interval exception_error: Sorry, an error occurred while executing the query @@ -3082,6 +3088,8 @@ en: active_notice: "We will deliver event details when it happens." categories_filter_instructions: "Relevant webhooks will only be triggered if the event is related with specified categories. Leave blank to trigger webhooks for all categories." categories_filter: "Triggered Categories" + tags_filter_instructions: "Relevant webhooks will only be triggered if the event is related with specified tags. Leave blank to trigger webhooks for all tags." + tags_filter: "Triggered Tags" groups_filter_instructions: "Relevant webhooks will only be triggered if the event is related with specified groups. Leave blank to trigger webhooks for all groups." groups_filter: "Triggered Groups" delete_confirm: "Delete this webhook?" @@ -3411,6 +3419,13 @@ en: settings: "Settings" templates: "Templates" preview_digest: "Preview Summary" + advanced_test: + title: "Advanced Test" + desc: "See how Discourse processes received emails. To be able to correctly process the email, please paste below the whole original email message." + email: "Original message" + run: "Run Test" + text: "Selected Text Body" + elided: "Elided Text" sending_test: "Sending test Email..." error: "ERROR - %{server_error}" test_error: "There was a problem sending the test email. Please double-check your mail settings, verify that your host is not blocking mail connections, and try again." @@ -3727,6 +3742,8 @@ en: suspended_until: "(until %{until})" cant_suspend: "This user cannot be suspended." delete_all_posts: "Delete all posts" + delete_posts_progress: "Deleting posts..." + delete_posts_failed: "There was a problem deleting the posts." penalty_post_actions: "What would you like to do with the associated post?" penalty_post_delete: "Delete the post" penalty_post_edit: "Edit the post" diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index f70ab654b5..575425b5dc 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -227,8 +227,8 @@ es: simple_title: "Acerca de" title: "Sobre %{title}" stats: "Estadísticas del sitio" - our_admins: "Nuestros Administradores" - our_moderators: "Nuestros Moderadores" + our_admins: "Nuestros administradores" + our_moderators: "Nuestros moderadores" stat: all_time: "Todo el tiempo" last_7_days: "Últimos 7" @@ -521,6 +521,12 @@ es: topic_sentence: one: "1 tema" other: "%{count} temas" + topic_stat_sentence_week: + one: "%{count} nuevo tema en la última semana." + other: "%{count} nuevos temas en la última semana." + topic_stat_sentence_month: + one: "%{count} nuevo tema en el último mes." + other: "%{count} nuevos temas en el último mes." n_more: "Categorías (%{count} más) ..." ip_lookup: title: Búsqueda de Direcciones IP @@ -1302,6 +1308,7 @@ es: toggle_direction: "Alternar dirección" help: "Ayuda de Edición con Markdown" collapse: "minimizar el panel de edición" + open: "abre el panel de composición" abandon: "cerrar el editor y descartar borrador" enter_fullscreen: "ingresar al editor en pantalla completa" exit_fullscreen: "salir del editor en pantalla completa" @@ -2830,6 +2837,8 @@ es: active_notice: "Enviaremos detalles del evento cuando suceda." categories_filter_instructions: "Los webhooks sólo se dispararán si el evento está relacionado con las categorías especificadas. Déjalo en blanco para disparar webhooks con todas las categorías." categories_filter: "Categorías que disparan" + tags_filter_instructions: "Los webhooks relevantes solo se activarán si el evento está relacionado con etiquetas específicas. Deje en blanco para activar webhooks para todas las etiquetas." + tags_filter: "Etiquetas activadas" groups_filter_instructions: "Los webhooks sólo se dispararán si el evento está relacionado con los grupos especificados. Déjalo en blanco para disparar webhooks con todos las grupos." groups_filter: "Grupos que disparan" delete_confirm: "¿Eliminar este webhook?" @@ -3153,6 +3162,13 @@ es: settings: "Ajustes" templates: "Plantillas" preview_digest: "Vista previa de Resumen" + advanced_test: + title: "Prueba Avanzada" + desc: "Vea como Discourse procesa los correos electrónicos recibidos. Para poder procesar correctamente el correo electrónico, pegue debajo del mensaje de correo electrónico original completo." + email: "Mensaje Original" + run: "Correr Prueba" + text: "Cuerpo del mensaje seleccionado" + elided: "Texto elidido" sending_test: "Enviando e-mail de prueba..." error: "ERROR - %{server_error}" test_error: "Hubo un error al enviar el email de prueba. Por favor, revisa la configuración de correo, verifica que tu servicio de alojamiento no esté bloqueando los puertos de conexión de correo, y prueba de nuevo." diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index a51a8db2ef..6339584a07 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -521,6 +521,12 @@ fr: topic_sentence: one: "1 sujet" other: "%{count} sujets" + topic_stat_sentence_week: + one: "%{count}nouveau sujet dans la dernière semaine." + other: "%{count} nouveaux sujets dans la dernière semaine." + topic_stat_sentence_month: + one: "%{count} nouveau sujet dans le dernier mois." + other: "%{count} nouveaux sujets dans le dernier mois." n_more: "Catégories (%{count}additionnelles) ..." ip_lookup: title: Rechercher l'adresse IP @@ -684,7 +690,7 @@ fr: choose_new: "Choisissez un nouveau mot de passe" choose: "Choisissez un mot de passe" second_factor_backup: - title: "Codes de secours de l'authentification à deux étapes" + title: "Codes de secours de l'authentification à deux facteurs" regenerate: "Regénérer" disable: "Désactiver" enable: "Activer" @@ -697,8 +703,8 @@ fr: title: "Codes de secours générés" description: "Chaque code de secours ne peut être utilisé qu'une seule fois. Garder les dans un endroit sûr mais accessible." second_factor: - title: "Authentification à deux étapes" - disable: "Désactiver l'authentification à deux étapes" + title: "Authentification à deux facteurs" + disable: "Désactiver l'authentification à deux facteurs" enable: "Activer l'authentification à deux facteurs pour une sécurité accrue de votre compte." confirm_password_description: "Merci de confirmer votre mot de passe pour continuer" label: "Code" @@ -708,7 +714,7 @@ fr: disable_description: "Veuillez saisir le code d'authentification de votre app" show_key_description: "Saisir manuellement" extended_description: "L'authentification à deux facteurs ajoute une sécurité supplémentaire à votre compte en exigeant un jeton unique en \nplus de votre mot de passe. Les jetons peuvent être générés sur les appareils Android et iOS.\n" - oauth_enabled_warning: "Veuillez noter que les connexions sociales seront désactivées une fois que l'authentification à deux étapes aura été activée sur votre compte." + oauth_enabled_warning: "Veuillez noter que les connexions sociales seront désactivées une fois que l'authentification à deux facteurs aura été activée sur votre compte." change_about: title: "Modifier À propos de moi" error: "Il y a eu une erreur lors de la modification de cette valeur." @@ -1087,10 +1093,10 @@ fr: title: "Se connecter" username: "Utilisateur" password: "Mot de passe" - second_factor_title: "Authentification à deux étapes" + second_factor_title: "Authentification à deux facteurs" second_factor_description: "Veuillez saisir le code d'authentification de votre app :" second_factor_backup: "Se connecter avec un code de secours" - second_factor_backup_title: "Authentification à deux étapes (code de secours)" + second_factor_backup_title: "Authentification à deux facteurs (code de secours)" second_factor_backup_description: "Veuillez entrer un de vos codes de secours :" second_factor: "Se connecter avec une application" email_placeholder: "courriel ou pseudo" @@ -1111,7 +1117,7 @@ fr: not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter depuis cette adresse IP." admin_not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter comme administrateur depuis cette adresse IP." resend_activation_email: "Cliquez ici pour envoyer à nouveau le courriel d'activation." - omniauth_disallow_totp: "L'authentification à deux étapes est activée sur votre compte. Veuillez vous connecter avec votre mot de passe." + omniauth_disallow_totp: "L'authentification à deux facteurs est activée sur votre compte. Veuillez vous connecter avec votre mot de passe." resend_title: "Renvoyer le courriel d'activation" change_email: "Changer l'adresse de courriel" provide_new_email: "Donnez une nouvelle adresse et nous allons renvoyer votre courriel de confirmation." @@ -2432,7 +2438,7 @@ fr: earned_n_times: one: "A reçu ce badge 1 fois" other: "A reçu ce badge %{count} fois" - granted_on: "Accordé le %{date}" + granted_on: "Accordé %{date}" others_count: "Autres utilisateurs avec ce badge (%{count})" title: Badges allow_title: "Vous pouvez utiliser ce badge comme titre" @@ -2844,6 +2850,8 @@ fr: active_notice: "Nous delivrerons les détails de l'évènement quand il se produit." categories_filter_instructions: "Les Webhooks appropriés seront uniquement déclenchés si les événements sont liés aux catégories définies. Laisser vide pour les déclencher pour toutes les catégories." categories_filter: "Catégories" + tags_filter_instructions: "Les Webhooks appropriés seront uniquement déclenchés si les événements sont liés aux tags définis. Laisser vide pour les déclencher pour tous les tags." + tags_filter: "Tags déclenchés" groups_filter_instructions: "Les Webhooks appropriés seront uniquement déclenchés si les événements sont liés aux groupes définis. Laisser vide pour les déclencher pour tous les groupes." groups_filter: "Groupes" delete_confirm: "Supprimer ce Webhook ?" @@ -3318,7 +3326,7 @@ fr: post_edit: "message modifié" post_unlocked: "message dévérouillé" check_personal_message: "contrôler messages directs" - disabled_second_factor: "désactiver l'authentification à deux étapes" + disabled_second_factor: "désactiver l'authentification à deux facteurs" topic_published: "sujet publié" post_approved: "message approuvé" post_rejected: "message rejeté" @@ -3520,7 +3528,7 @@ fr: private_topics_count: Sujets privés posts_read_count: Messages lus post_count: Messages créés - second_factor_enabled: Authentification à deux étapes activée + second_factor_enabled: Authentification à deux facteurs activée topics_entered: Sujets vus flags_given_count: Signalements effectués flags_received_count: Signalements reçus diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 31a2a8cea6..9e4ac2350e 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -794,6 +794,7 @@ it: always: "sempre" never: "mai" email_digests: + title: "Quando non visito il sito da un po', inviami un'email con il sommario degli Argomenti e delle risposte più popolari" every_30_minutes: "ogni 30 minuti" every_hour: "ogni ora" daily: "ogni giorno" @@ -1299,6 +1300,8 @@ it: shared_draft: label: "Bozza Condivisa" desc: "Prepara la bozza di un argomento che sarà visibile solo allo Staff" + toggle_topic_bump: + desc: "Rispondi senza cambiare la data dell'ultima risposta" notifications: tooltip: regular: diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index 063f868b96..b6bc50ac74 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -736,8 +736,10 @@ pl_PL: choose_new: "Wyberz nowe hasło" choose: "Wybierz hasło" second_factor_backup: + regenerate: "Odnów" disable: "Wyłącz" enable: "Włącz" + copied_to_clipboard: "Skopiowane do schowka" second_factor: title: "Dwuskładnikowe uwierzytelnianie" disable: "Wyłącz dwuskładnikowe uwierzytelnianie" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index bdf76f8723..3715f5333d 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -150,7 +150,7 @@ pt_BR: topic_admin_menu: "ações administrativas do tópico" wizard_required: "Bem vindo ao seu novo Discourse! Vamos começar com o assistente de configuração ✨" emails_are_disabled: "Todo o envio de email foi globalmente desabilitado por algum administrador. Nenhum email de notificações de qualquer tipo será enviado." - bootstrap_mode_enabled: "Para tornar o lançamento do seu novo site mais fácil, você está no modo de bootstrap. Todos os novos usuários receberão o nível de confiança 1 e terão as atualizações diárias de resumos de e-mail ativadas. Isso será desativado automaticamente quando %{min_users} dos usuários entrarem." + bootstrap_mode_enabled: "Para facilitar o lançamento do seu novo site, você está no modo de bootstrap. Todos os novos usuários receberão o nível de confiança 1 e terão os e-mails diários de resumos ativados. Isso será desativado automaticamente quando %{min_users} usuários entrarem." bootstrap_mode_disabled: "O modo Bootstrap será desativado em 24 horas." themes: default_description: "Padrão" @@ -244,6 +244,7 @@ pt_BR: unbookmark: "Clique para remover todos os favoritos neste tópico" bookmarks: created: "você favoritou essa resposta" + not_bookmarked: "favoritar essa resposta" remove: "Remover favorito" drafts: resume: "Resuma" @@ -510,6 +511,7 @@ pt_BR: topic_sentence: one: "1 tópico" other: "%{count} tópicos" + n_more: "Categorias (mais %{count}) ..." ip_lookup: title: Pesquisa do endereço de IP hostname: Nome do host @@ -952,6 +954,9 @@ pt_BR: enabled: "Este site está em modo de leitura apenas. Por favor continue a navegar, no entanto, respostas, curtidas e outras ações estão desativadas por enquanto." login_disabled: "O login é desativado enquanto o site está em modo de somente leitura." logout_disabled: "O logout é desativado enquanto o site está em modo de somente leitura." + too_few_topics_and_posts_notice: "Vamos começar essa discussão! Há atualmente %{currentTopics} / %{requiredTopics} tópicos e %{currentPosts} / %{requiredPosts} postagens. Novos visitantes precisam de alguma conversas para ler e responder." + too_few_topics_notice: "Vamos começar essa discussão! Há atualmente %{currentTopics} / %{requiredTopics} tópicos. Novos visitantes precisam de algumas conversar para ler e responder." + too_few_posts_notice: "Vamos começar essa discussão! Há atualmente %{currentPosts} / %{requiredPosts} postagens. Novos visitantes precisam de algumas conversas para ler e responder." logs_error_rate_notice: reached: "%{relativeAge}%{rate} alcançou a configuração limite do site de %{siteSettingRate}." exceeded: "%{relativeAge}%{rate} excedeu a configuração limite do site de %{siteSettingRate}." @@ -998,9 +1003,10 @@ pt_BR: disable: "Mostrar Posts Deletados" private_message_info: title: "Mensagem" - leave_message: "Você quer mesmo deixar esta mensagem?" - remove_allowed_user: "Tem a certeza que deseja remover {{name}} desta mensagem?" - remove_allowed_group: "Tem a certeza que deseja remover {{name}} desta mensagem?" + edit: "Adicionar ou Remover ..." + leave_message: "Você quer mesmo sair desta mensagem?" + remove_allowed_user: "Tem certeza que deseja remover {{name}} desta mensagem?" + remove_allowed_group: "Tem certeza que deseja remover {{name}} desta mensagem?" email: 'Email' username: 'Nome de Usuário' last_seen: 'Visto' @@ -1045,7 +1051,7 @@ pt_BR: second_factor_backup_title: "Backup de dois fatores" second_factor_backup_description: "Por favor, insira um dos seus códigos de backup:" second_factor: "Faça o login usando o aplicativo Authenticator" - email_placeholder: "e-mail ou Nome de Usuário" + email_placeholder: "e-mail ou nome de usuário" caps_lock_warning: "CAIXA ALTA está ligado" error: "Erro desconhecido" cookies_error: "Seu navegador parece ter cookies desativados. Você pode não conseguir efetuar login sem ativá-los primeiro." @@ -1621,7 +1627,7 @@ pt_BR: "2_8": 'Você verá uma contagem de novas respostas porque está acompanhando essa categoria.' "2_4": 'Você verá uma contagem de novas respostas porque postou uma resposta a este tópico.' "2_2": 'Você verá uma contagem de novas respostas porque está acompanhando este tópico.' - "2": 'Você verá uma contagem de novas respostas porqueleia este tópico.' + "2": 'Você verá uma contagem de novas respostas porque você leu este tópico.' "1_2": 'Você será notificado se alguém mencionar o seu @nome ou responder à sua mensagem.' "1": 'Você será notificado se alguém mencionar o seu @nome ou responder à sua mensagem.' "0_7": 'Você está ignorando todas as notificações nessa categoria.' @@ -1675,7 +1681,7 @@ pt_BR: remove_banner: "Remover Banner Tópico" reply: title: 'Responder' - help: 'comece a compor uma resposta a este tópico' + help: 'começar a escrever uma resposta para este tópico' clear_pin: title: "Remover destaque" help: "Retirar destaque deste tópico para que ele não apareça mais no topo da sua lista de tópicos" @@ -1719,7 +1725,7 @@ pt_BR: automatically_add_to_groups: "Este convite também inclui acesso para esses grupos:" invite_private: title: 'Convidar para Conversa Privada' - email_or_username: "Email ou Nome de Usuário do convidado" + email_or_username: "E-mail ou Nome de Usuário do Convidado" email_or_username_placeholder: "email ou Nome de Usuário" action: "Convite" success: "Nós convidamos aquele usuário para participar desta mensagem privada." @@ -1730,7 +1736,7 @@ pt_BR: invite_reply: title: 'Convite' username_placeholder: "nome de usuário" - action: 'Enviar Convites' + action: 'Enviar Convite' help: 'Convidar outros para este tópico por email ou notificação' to_forum: "Nós vamos mandar um email curto permitindo seu amigo a entrar e responder a esse tópico clicando em um link, sem necessidade de entrar." sso_enabled: "Entrar o nome de usuário da pessoa que você gostaria de convidar para este tópico." @@ -1769,9 +1775,13 @@ pt_BR: action: "unificar as mensagens selecionadas" error: "Houve um erro ao unificar as mensagens selecionadas." change_owner: + title: "Trocar Autor" action: "trocar autor" error: "Houve um erro ao alterar o autor dessas mensagens." placeholder: "novo autor" + instructions: + one: "Por favor escolha um novo autor para a postagem de @{{old_user}}" + other: "Por favor escolha um novo autor para as {{count}} postagens de @{{old_user}}" change_timestamp: title: "Alterar o timestamp ..." action: "alterar horário" @@ -1858,14 +1868,14 @@ pt_BR: save: 'Salvar as opções' few_likes_left: "Obrigado por compartilhar o amor! Restam apenas algumas poucas curtidas sobrando para você usar hoje." controls: - reply: "comece a compor uma resposta para este tópico" + reply: "começar a escrever uma resposta para esta postagem" like: "curtir esta resposta" has_liked: "você curtiu essa resposta" undo_like: "desfazer curtida" edit: "editar esta resposta" edit_action: "Editar" edit_anonymous: "Você precisa estar conectado para editar essa resposta." - flag: "sinalize privativamente esta resposta para chamar atenção ou enviar uma notificação privada sobre ela" + flag: "sinalizar em privado para chamar atenção a esta resposta ou enviar uma notificação privada sobre ela" delete: "apagar esta resposta" undelete: "recuperar esta resposta" share: "compartilhar o link desta resposta" @@ -1879,15 +1889,15 @@ pt_BR: one: "Sim e 1 resposta" other: "Sim, e todas as {{count}} respostas" just_the_post: "Não, apenas este post" - admin: "ações de mensagens do admin" + admin: "ações administrativas da postagem" wiki: "Tornar Wiki" unwiki: "Remover Wiki" convert_to_moderator: "Converter para Moderação" revert_to_regular: "Remover da Moderação" rebake: "Reconstruir HTML" unhide: "Revelar" - change_owner: "Trocar autor" - grant_badge: "Grant Badge" + change_owner: "Trocar Autor" + grant_badge: "Conceder Emblema" lock_post: "Bloquear Post" lock_post_description: "impedir que o pôster edite este post" unlock_post: "Desbloquear postagem" @@ -2563,7 +2573,7 @@ pt_BR: active_posts: "Posts sinalizados" old_posts: "Posts antigos sinalizados" topics: "Tópicos sinalizados" - moderation_history: "Moderação Histórico" + moderation_history: "Histórico de Moderação" agree: "Concordo" agree_title: "Confirmar esta marcação como válida e correta" agree_flag_hide_post: "Esconder publicação" diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index 7fc8f02e44..c13c2f6e31 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -568,6 +568,7 @@ ro: private_messages: "Mesaje" activity_stream: "Activitate" preferences: "Preferințe" + profile_hidden: "Acest profil public al utilizatorului este ascuns." expand_profile: "Extinde" collapse_profile: "Colaps" bookmarks: "Semne de carte" @@ -691,6 +692,7 @@ ro: error: "A apărut o eroare la schimbarea acestei valori." change_username: title: "Schimbă numele utilizatorului" + confirm: "Ești absolut sigur că vrei să-ți schimbi numele de utilizator?" taken: "Acest nume de utilizator este deja folosit." invalid: "Acest nume de utilizator este invalid. Trebuie să includă doar cifre și litere." change_email: @@ -751,6 +753,8 @@ ro: any: "oricare" password_confirmation: title: "Confirmă parola" + auth_tokens: + details: "Detalii" last_posted: "Ultima postare" last_emailed: "Ultimul email" last_seen: "Văzut " diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index a99058a751..7e02f6d36c 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -1012,6 +1012,7 @@ ru: most_liked_users: "Фавориты" most_replied_to_users: "Самые активные собеседники" no_likes: "Пока ни одной симпатии." + replies: "Отвечает" ip_address: title: "Последний IP адрес" registration_ip_address: @@ -2165,6 +2166,7 @@ ru: allow_badges_label: "Разрешить вручение наград в этом разделе" edit_permissions: "Изменить права доступа" add_permission: "Добавить права" + require_reply_approval: "Требовать одобрения модератором всех новых ответов" this_year: "за год" default_position: "Позиция по умолчанию" position_disabled: "Разделы будут показаны в порядке активности. Чтобы настроить порядок разделов," diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index b7d4c853b5..0f449fc6b1 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -9,12 +9,17 @@ sl: js: number: format: - separator: "." - delimiter: "," + separator: "," + delimiter: "." human: storage_units: format: '%n %u' units: + byte: + one: Bajt + two: Bajta + few: Bajti + other: Bajtov gb: GB kb: KB mb: MB @@ -49,6 +54,11 @@ sl: two: "%{count} s" few: "%{count} s" other: "%{count} s" + less_than_x_minutes: + one: "< 1 min" + two: "< %{count} min" + few: "< %{count} min" + other: "< %{count} min" x_minutes: one: "%{count} min" two: "%{count} min" @@ -64,6 +74,11 @@ sl: two: "%{count} d" few: "%{count} d" other: "%{count} d" + x_months: + one: "1 mes" + two: "%{count} mes" + few: "%{count} mes" + other: "%{count} mes" about_x_years: one: "%{count} l" two: "%{count} l" @@ -143,12 +158,14 @@ sl: email: 'pošlji povezavo preko e-pošte' action_codes: public_topic: "je naredil temo javno %{when}" + private_topic: "je spremenil temo v osebno sporočilo %{when}" split_topic: "je razdelil temo %{when}" invited_user: "je povabil %{who} %{when}" invited_group: "je povabil %{who} %{when}" user_left: "%{who}se je odstranil s tega sporočila %{when}" removed_user: "je odstranil %{who} %{when}" removed_group: "je odstranil %{who} %{when}" + autobumped: "samodejno izpostavljeno %{when}" autoclosed: enabled: 'zaprto %{when}' disabled: 'odprto %{when}' @@ -196,6 +213,7 @@ sl: not_implemented: "Oprosti, ta funkcija še ni bila implementirana!" no_value: "Ne" yes_value: "Da" + submit: "Oddaj" generic_error: "Ups, prišlo je do napake." generic_error_with_reason: "Napaka: %{error}" sign_up: "Prijava" @@ -217,6 +235,7 @@ sl: privacy_policy: "Politika zasebnost" privacy: "Zasebnost" tos: "Pogoji storitve" + rules: "Pravila" mobile_view: "Mobilni pogled" desktop_view: "Namizni pogled" you: "Ti" @@ -239,6 +258,8 @@ sl: two: "{{count}} znaka" few: "{{count}} znakov" other: "{{count}} znakov" + related_messages: + title: "Povezana sporočila" suggested_topics: title: "Predlagane Teme" pm_title: "Predlagana Sporočila" @@ -250,6 +271,8 @@ sl: our_moderators: "Naši Moderatorji" stat: all_time: "Vse" + last_7_days: "Zadnjih 7" + last_30_days: "Zadnjih 30" like_count: "Všečki" topic_count: "Teme" post_count: "Objave" @@ -265,7 +288,34 @@ sl: unbookmark: "Klikni za odstranitev vseh zaznamkov v tej temi" bookmarks: created: "ustvarili ste zaznamek te objave" + not_bookmarked: "Zaznamuj objavo" remove: "Odstrani Zaznamek" + confirm_clear: "Ste prepričani, da želite počistiti vse svoje zaznamke iz te teme?" + drafts: + resume: "Nadaljuj" + remove: "Odstrani" + new_topic: "Nov osnutek teme" + new_private_message: "Nov osnutek osebnega sporočila" + topic_reply: "Osnutek odgovora" + abandon: + confirm: "V tej temi že imate drug osnutek. Ste prepričani, da ga želite opustiti?" + yes_value: "Da, opusti" + no_value: "Ne, obdrži" + topic_count_latest: + one: "Poglej {{count}} novo ali posodobljeno temo" + two: "Poglej {{count}} novi ali posodobljeni temi" + few: "Poglej {{count}} nove ali posodobljene teme" + other: "Poglej {{count}} novih ali posodobljenih tem" + topic_count_unread: + one: "Poglej {{count}} neprebrano temo" + two: "Poglej {{count}} neprebrani temi" + few: "Poglej {{count}} neprebrane teme" + other: "Poglej {{count}} neprebranih tem" + topic_count_new: + one: "Poglej {{count}} novo temo" + two: "Poglej {{count}} novi temi" + few: "Poglej {{count}} nove teme" + other: "Poglej {{count}} novih tem" preview: "predogled" cancel: "prekliči" save: "Shrani Spremembe" @@ -273,10 +323,13 @@ sl: saved: "Shranjeno!" upload: "Naloži" uploading: "Nalagam..." + uploading_filename: "Nalagam: {{filename}} ..." + clipboard: "odložišče" uploaded: "Naloženo!" pasting: "Prilepljam..." enable: "Omogoči" disable: "Onemogoči" + continue: "Nadaljuj" undo: "Razveljavi" revert: "Povrni" failed: "Spodletelo" @@ -360,6 +413,35 @@ sl: make_user_group_owner: "Spremeni v lastnika" remove_user_as_group_owner: "Odstrani lastništvo" groups: + member_added: "Dodano" + add_members: + title: "Dodaj člane" + description: "Upravljaj članstvo te skupine" + usernames: "Uporabniška imena" + manage: + title: 'Upravljaj' + name: 'Ime' + full_name: 'Polno ime' + add_members: "Dodaj člane" + delete_member_confirm: "Odstrani '%{username}' iz skupine '%{group}'?" + profile: + title: Profil + interaction: + posting: Objave + notification: Obvestila + membership: + title: Članstvo + access: Dostopnost + logs: + title: "Dnevniki" + when: "Kdaj" + action: "Dejanje" + acting_user: "Izvajalec" + target_user: "Ciljni uporabnik" + subject: "Zadeva" + details: "Podrobnosti" + from: "Od" + to: "Za" public_admission: "Dovoli uporabnikom, da se sami pridružijo skupini (skupina mora biti javna)" public_exit: "Dovoli uporabnikom da sami zapustijo skupino" empty: @@ -370,6 +452,9 @@ sl: topics: "Člani te skupine nimajo nobene teme." logs: "Dnevnik za to skupino je prazen." add: "Dodaj" + join: "Pridruži se" + leave: "Zapusti" + request: "Zahteva" message: "Sporočilo" allow_membership_requests: "Dovoli uporabnikom, da zaprosijo za članstvo pri lastnikih skupine" membership_request_template: "Predloga za prikaz uporabnikom, ko zaprosijo za članstvo" @@ -379,18 +464,47 @@ sl: reason: "Sporoči lastnikom skupine zakaj spadaš v to skupino" membership: "Članstvo" name: "Ime" + group_name: "Ime skupine" + user_count: "Uporabniki" bio: "O skupini" selector_placeholder: "vnesi uporabniško ime" owner: "lastnik" index: title: "Skupine" + all: "Vse skupine" empty: "Ni javnih skupin." + filter: "Filtriraj po tipu skupine" + owner_groups: "Sem skrbnik skupin" + close_groups: "Zaprte skupine" + automatic_groups: "Samodejne skupine" + automatic: "Samodejno" + closed: "Zaprto" + public: "Javno" + private: "Zasebno" + public_groups: "Javne skupine" + automatic_group: Samodejna skupina + close_group: Zapri skupino + my_groups: "Moje skupine" + group_type: "Vrsta skupine" + is_group_user: "Član" + is_group_owner: "Skrbnik" title: one: "Skupina" two: "Skupini" few: "Skupin" other: "Skupin" activity: "Aktivnost" + members: + title: "Člani" + filter_placeholder_admin: "uporabniško ime ali e-pošta" + filter_placeholder: "uporabniško ime" + remove_member: "Odstrani člana" + remove_member_description: "Odstrani %{username} iz te skupine" + make_owner: "Izberi za skrbnika" + make_owner_description: "Izberi %{username} za skrbnika te skupine" + remove_owner: "Odstrani kot skrbnika" + remove_owner_description: "Odstrani %{username} kot skrbnika te skupine" + owner: "Skrbnik" topics: "Teme" posts: "Objave" mentions: "Omembe" @@ -436,8 +550,10 @@ sl: "12": "Poslano" "13": "Prejeto" "14": "Čaka odobritev" + "15": "Osnutki" categories: all: "vse kategorije" + all_subcategories: "vse" no_subcategory: "nič" category: "Kategorija" category_list: "Prikaži seznam kategorij" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index cddedb4cea..e16b60fc60 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -450,6 +450,7 @@ zh_CN: description: "你不会收到组内关于新主题中的任何通知。" flair_url: "头像图片" flair_url_placeholder: "(可选)图片 URL 或 Font Awesome class" + flair_url_description: "使用不小于20px × 20px的方形图像或FontAwesome图标(可接受的格式:“fa-icon”,“far fa-icon”或“fab fa-icon”)。" flair_bg_color: "头像背景颜色" flair_bg_color_placeholder: "(可选)十六进制色彩值" flair_color: "头像颜色" @@ -490,6 +491,10 @@ zh_CN: subcategories: "子分类" topic_sentence: other: "%{count} 主题" + topic_stat_sentence_week: + other: "%{count}上周新帖。" + topic_stat_sentence_month: + other: "%{count}上月新帖。" n_more: "分类 (还有%{count}个分类) ..." ip_lookup: title: IP 地址查询 @@ -1199,6 +1204,7 @@ zh_CN: title_too_long: "标题过长,最多 {{max}} 个字" post_missing: "帖子不能为空" post_length: "帖子至少应有 {{min}} 个字" + try_like: "试试{{heart}}按钮?" category_missing: "未选择分类" tags_missing: "你必须至少选择{{count}}个标签" save_edit: "保存编辑" @@ -1254,7 +1260,8 @@ zh_CN: list_item: "列表条目" toggle_direction: "切换方向" help: "Markdown 编辑帮助" - collapse: "最小号编辑面板" + collapse: "最小化编辑面板" + open: "打开编辑面板" abandon: "关闭编辑面板并放弃草稿" enter_fullscreen: "进入全屏编辑模式" exit_fullscreen: "退出全屏编辑模式" @@ -1958,6 +1965,7 @@ zh_CN: revert: "还原至该版本" edit_wiki: "编辑维基" edit_post: "编辑帖子" + comparing_previous_to_current_out_of_total: "{{previous}} {{icon}} {{current}} / {{total}}" displays: inline: title: "行内显示渲染后的页面,并标示增加和删除的内容" @@ -2493,7 +2501,7 @@ zh_CN: activity_metrics: 活跃指标 all_reports: "所有报告" general_tab: "常规" - moderation_tab: "适度" + moderation_tab: "管理" disabled: 停用 timeout_error: 对不起,查询时间太长,请选择较短的间隔 exception_error: 抱歉,执行查询时发生错误 @@ -2707,6 +2715,8 @@ zh_CN: active_notice: "当事件发生时,我们会推送细节" categories_filter_instructions: "相关 Webhook 事件将在满足特定分类的情况下才发送。留空忽略分类限制。" categories_filter: "触发的分类" + tags_filter_instructions: "相关Webhook事件将在满足特定标签的情况下才发送。留空忽略标签限制。" + tags_filter: "触发的标签" groups_filter_instructions: "相关 Webhook 事件将在满足特定群组的情况下才发送。留空忽略群组限制。" groups_filter: "触发的群组" delete_confirm: "删除这个 webhook?" @@ -3027,6 +3037,13 @@ zh_CN: settings: "设置" templates: "模板" preview_digest: "预览" + advanced_test: + title: "高级测试" + desc: "了解Discourse如何处理收到的电子邮件。为了能够正确处理电子邮件,请在下方粘贴整个原始邮件。" + email: "原始消息" + run: "运行测试" + text: "已选择的测试正文" + elided: "省略文本" sending_test: "发送测试邮件…" error: "错误 - %{server_error}" test_error: "发送测试邮件时遇到问题。请再检查一遍邮件设置,确认你的主机没有封锁邮件链接,然后重试。" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 2d4994e9dc..827d01921b 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -35,6 +35,7 @@ de: anonymous: "Anonym" remove_posts_deleted_by_author: "Gelöscht vom Verfasser" redirect_warning: "Wir konnten nicht überprüfen, dass der ausgewählte Link tatsächlich im Forum geschrieben wurde. Wenn du trotzdem fortfahren möchtest, wähle den Link unten aus." + on_another_topic: "Zu einem anderen Thema" themes: bad_color_scheme: "Kann Motiv nicht aktualisieren, ungültiges Farbschema" other_error: "Etwas ist schief gelaufen beim Aktualisieren des Theme" @@ -182,14 +183,12 @@ de: provider_not_enabled: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsprovider ist nicht aktiviert." provider_not_found: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsprovider existiert nicht." read_only_mode_enabled: "Die Seite befindet sich im Nur-Lesen Modus. Änderungen sind deaktiviert." + invalid_grant_badge_reason_link: "Externe oder ungültige Discourse-Links sind nicht erlaubt in der Abzeichen-Begründung." reading_time: "Lesezeit" likes: "Likes" too_many_replies: one: "Entschuldigung, aber neue Benutzer sind vorübergehend auf eine Antwort pro Thema beschränkt." other: "Entschuldigung, aber neue Benutzer sind vorübergehend auf %{count} Antworten pro Thema beschränkt." - max_consecutive_replies: - one: "Es ist nicht mehr als eine Antwort erlaubt. Bitte bearbeite stattdessen deine vorherige Antwort oder warte, bis dir jemand antwortet." - other: "Es sind nicht mehr als %{count} aufeinanderfolgende Antworten erlaubte. Bitte bearbeite stattdessen deine letzte Antwort oder warte, bis dir jemand antwortet." embed: start_discussion: "Diskussion beginnen" continue: "Diskussion fortsetzen" @@ -1380,6 +1379,7 @@ de: title_min_entropy: "Für Titel neuer Themen minimal erforderliche Entropie (einzigartige Zeichen)." body_min_entropy: "Für den Text neuer Beiträge minimal erforderliche Entropie (einzigartige Zeichen)." allow_uppercase_posts: "Beiträge oder Titel von Themen mit ausschließlich Großschreibweise erlauben." + max_consecutive_replies: "Anzahl an aufeinanderfolgenden Beiträgen, die ein Benutzer in einem Thema schreiben kann, bevor eine weitere Antwort verhindert wird." title_fancy_entities: "Konvertiere HTML-Entitäten in Themen-Überschriften, nach dem Smarty-Pants-Schema https://daringfireball.net/projects/smartypants/" min_title_similar_length: "Minimale Länge eines Titels, bevor nach ähnlichen Titeln gesucht wird." desktop_category_page_style: "Visueller Stil für die /categories Seite" @@ -1529,6 +1529,7 @@ de: native_app_install_banner: "Wiederkehrende Benutzer dazu einladen, die native Discourse-App herunterzuladen." share_anonymized_statistics: "Anonymisierte Nutzungsdaten teilen." auto_handle_queued_age: "Bearbeite Einträge automatisch, die so viele Tage auf Überprüfung warten. Meldungen werden ignorieren. Beiträge und Benutzer in der Warteschlange werden zurückgewiesen. Setze den Wert auf 0, um diese Funktion zu deaktivieren." + svg_icon_subset: "Füge zusätzliche Font Awesome 5 Icons hinzu, die du verfügbar machen möchtest. Verwende das Präfix 'fa-' für Icons der Kategorie 'solid', 'far-' für die Kategorie 'regular' und 'fab-' für die Kategorie 'brand'." max_prints_per_hour_per_user: "Maximale Anzahl von Aufrufen der Druckansicht pro Nutzer pro Stunde (0 zum deaktivieren)" full_name_required: "Der voller Name wird für das Benutzerprofil benötigt." enable_names: "Zeigt den vollen Namen eines Benutzers auf dem Profil, der Benutzerkarte und in E-Mails an. Wenn deaktiviert wird der volle Name überall ausgeblendet." @@ -1617,6 +1618,7 @@ de: shared_drafts_category: "Aktiviere die Funktion „Gemeinsame Vorlagen“, indem du eine Kategorie für Themen-Vorlagen bestimmst. Themen in dieser Kategorie werden unterdrückt in der Themen Liste für Team Mitarbeiter." push_notifications_prompt: "Zeige eine Aufforderung zur Benutzerzustimmung an." push_notifications_icon: "Das Abzeichen-Icon, das in der Benachrichtigungsecke erscheint. Empfohlene Größe ist 96px × 96px." + short_title: "Der Kurztitel wird Benutzern auf dem Startbildschirm, im Startmenü oder an anderen Stellen dargestellt, an denen der Platz begrenzt ist. Ein Maximum von 12 Zeichen wird empfohlen." errors: invalid_email: "Ungültige E-Mail-Ad­res­se" invalid_username: "Es gibt keinen Benutzer mit diesem Benutzernamen." @@ -2380,6 +2382,14 @@ de: Es tut uns leid aber wir haben Probleme Dich per E-Mail zu erreichen. Unsere letzten E-Mails an Dich sind alle als unzustellbar zurück gekommen. Bitte stelle sicher, dass [E-Mail Adresse](%{base_url}/my/preferences/email) gültig und aktiv ist? Füge bitte unsere E-Mail Adresse in Deinem Adressbuch hinzu, damit die Zustellbarkeit verbessert wird. + email_bounced: | + Die Nachricht an %{email} konnte nicht zugestellt werden. + + ### Details + + ```text + %{raw} + ``` too_many_spam_flags: title: "Neues Konto gesperrt wegen zu viel Spam" subject_template: "Neues Konto gesperrt" @@ -3055,6 +3065,178 @@ de: Ja, die rechtliche Seite ist langweilig, aber wir müssen uns selbst schützen – und im erweiterten Sinne auch dich und deine Daten – vor unfreundlichen Leuten. Wir haben [Nutzungsbedingungen](%{base_path}/tos), die deines (und unser) Verhalten und unsere Rechte bezüglich des Inhalts, der Privatsphäre und den Gesetzen beschreiben. Um diesen Dienst zu nutzen, musst du dich mit unseren [Nutzungsbedingungen](%{base_path}/tos) einverstanden erklären. tos_topic: title: "Nutzungsbedingungen" + body: | + Diese Bedingungen regeln die Nutzung des Internetforums unter <%{base_url}>. Um das Forum zu nutzen, musst du diesen Bedingungen mit%{company_name} zustimmen, dem Betreiber des Forums. + + Der Betreiber kann andere Produkte und Dienstleistungen zu unterschiedlichen Bedingungen anbieten. Diese Bedingungen gelten nur für die Nutzung des Forums. + + Springe zu: + + - [Wichtige Bedingungen](#heading--important-terms) + - [Erlaubnis zur Nutzung des Forums](#heading--permission) + - [Bedingungen für die Nutzung des Forums](#heading--conditions) + - [Zulässige Nutzung](#heading--acceptable-use) + - [Zulässige Inhalte](#heading--content-standards) + - [Rechtsdurchsetzung](#heading--enforcement) + - [Deine Konto](#heading--your-account) + - [Deine Inhalte](#heading--your-content) + - [Deine Pflichten](#heading--responsibility) + - [Haftungsausschlüsse](#heading--disclaimers) + - [Haftungsbeschränkungen](#heading--liability) + - [Feedback](#heading--feedback) + - [Kündigung](#heading--termination) + - [Streitfälle](#heading--disputes) + - [Allgemeine Bedingungen](#heading--general) + - [Kontakt](#heading--contact) + - [Änderungen](#heading--changes) + +

Wichtige Bedingungen

+ + ***Diese Bedingungen beinhalten eine Reihe wichtiger Bestimmungen, die Ihre Rechte und Pflichten betreffen, wie z.B. [Haftungsausschlüsse](#heading--disclaimers), [Haftungsbeschränkungen](#heading--liability), dein Einverständnis, durch Missbrauch des Forums verursachte Schäden gegenüber dem Betreiber zu decken unter [Deine Pflichten](#heading--responsibility), und eine Vereinbarung zur Schlichtung von Streitfällen unter [Streitfälle](#heading--disputes).*** + +

Erlaubnis zur Nutzung des Forums

+ + Vorbehaltlich dieser Bedingungen erteilt dir der Betreiber die Erlaubnis, das Forum zu nutzen. Jeder muss diesen Bedingungen zustimmen, um das Forum nutzen zu können. + +

Bedingungen für die Nutzung des Forums

+ + Deine Erlaubnis zur Nutzung des Forums unterliegt den folgenden Bedingungen: + + 1. Du musst mindestens dreizehn Jahre alt sein. + + 2. Du darfst das Forum nicht mehr nutzen, wenn sich der Betreiber direkt mit dir in Verbindung setzt, um dir mitzuteilen, dass du es nicht mehr nutzen darfst. + + 3. Du musst das Forum in Übereinstimmung mit den Standards für [zulässige Nutzung](#heading--acceptable-use) und [zulässige Inhalte](#heading--content-standards) nutzen. + +

Zulässige Nutzung

+ + 1. Du darfst nicht gegen das Gesetz verstoßen, wenn du das Forum benutzt. + + 2. Du darfst das Konto eines anderen im Forum ohne dessen ausdrückliche Erlaubnis nicht nutzen oder versuchen, es zu nutzen. + + 3. Du darfst keine Benutzernamen oder andere eindeutige Identifikatoren im Forum kaufen, verkaufen oder anderweitig handeln. + + 4. Du darfst keine Werbung, Kettenbriefe oder andere Aufforderungen über das Forum senden oder das Forum nutzen, um Adressen oder andere persönliche Daten für kommerzielle Mailinglisten oder Datenbanken zu sammeln. + + 5. Du darfst den Zugriff auf das Forum nicht automatisieren oder das Forum überwachen, z.B. mit einem Webcrawler, Browser-Plugin oder -Add-on oder einem anderen Computerprogramm, das kein Webbrowser ist. Sie können das Forum durchsuchen, um es für eine öffentlich zugängliche Suchmaschine zu indizieren, falls Sie eine solche betreiben. + + 6. Du darfst das Forum nicht nutzen, um E-Mails an Verteilerlisten, Newsgroups oder Gruppenmail-Aliase zu senden. + + 7. Du darfst nicht fälschlicherweise andeuten, dass du mit dem Betreiber verbunden bist oder von diesem unterstützt wirst. + + 8. Du darfst keinen Hyperlink zu Bildern oder anderen nicht-hypertextuellen Inhalten im Forum auf anderen Webseiten setzen. + + 9. Du darfst keine Marken entfernen, die Eigentum an Materialien zeigen, die du vom Forum herunterlädst. + + 10. Du darfst keinen Teil des Forums auf anderen Websites mit `