diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index b906767d01..f2f17a729c 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: ember-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }} + cancel-in-progress: true + jobs: build: name: run @@ -45,7 +49,23 @@ jobs: working-directory: ./app/assets/javascripts/discourse run: yarn install - - name: Core QUnit + - name: Ember Build working-directory: ./app/assets/javascripts/discourse - run: sudo -E -u discourse -H yarn ember test --launch "${{ matrix.browser }}" + run: | + sudo -E -u discourse mkdir /tmp/emberbuild + sudo -E -u discourse -H yarn ember build --environment=test -o /tmp/emberbuild + + - name: Core QUnit 1 + working-directory: ./app/assets/javascripts/discourse + run: sudo -E -u discourse -H yarn ember exam --path /tmp/emberbuild --split=3 --partition=1 --launch "${{ matrix.browser }}" + timeout-minutes: 60 + + - name: Core QUnit 2 + working-directory: ./app/assets/javascripts/discourse + run: sudo -E -u discourse -H yarn ember exam --path /tmp/emberbuild --split=3 --partition=2 --launch "${{ matrix.browser }}" + timeout-minutes: 60 + + - name: Core QUnit 3 + working-directory: ./app/assets/javascripts/discourse + run: sudo -E -u discourse -H yarn ember exam --path /tmp/emberbuild --split=3 --partition=3 --launch "${{ matrix.browser }}" timeout-minutes: 60 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index ac5b712d03..b1e756e2bd 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: linting-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }} + cancel-in-progress: true + jobs: build: name: run diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21234f82ad..7f52de106f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: tests-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }} + cancel-in-progress: true + jobs: build: name: ${{ matrix.target }} ${{ matrix.build_type }} diff --git a/Gemfile.lock b/Gemfile.lock index 6cbb820298..eb80bdea03 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -80,7 +80,7 @@ GEM rack (>= 0.9.0) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) - bootsnap (1.9.1) + bootsnap (1.9.3) msgpack (~> 1.0) builder (3.2.4) bullet (6.1.5) @@ -161,7 +161,7 @@ GEM ffi (1.15.4) fspath (3.1.2) gc_tracer (1.5.1) - globalid (0.5.2) + globalid (1.0.0) activesupport (>= 5.0) guess_html_encoding (0.0.11) hana (1.3.7) @@ -198,6 +198,8 @@ GEM jwt (2.3.0) kgio (2.11.4) libv8-node (16.10.0.0) + libv8-node (16.10.0.0-aarch64-linux) + libv8-node (16.10.0.0-arm64-darwin) libv8-node (16.10.0.0-x86_64-darwin) libv8-node (16.10.0.0-x86_64-darwin-19) libv8-node (16.10.0.0-x86_64-linux) @@ -213,7 +215,7 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.9.8) + logster (2.10.0) loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -290,7 +292,7 @@ GEM parallel (1.21.0) parallel_tests (3.7.3) parallel - parser (3.0.2.0) + parser (3.0.3.1) ast (~> 2.4.1) pg (1.2.3) progress (3.6.0) @@ -387,7 +389,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.22.3) + rubocop (1.23.0) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) @@ -440,7 +442,7 @@ GEM sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.3.0) + sprockets-rails (3.4.1) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) @@ -474,6 +476,7 @@ GEM zeitwerk (2.5.1) PLATFORMS + aarch64-linux arm64-darwin-20 ruby x86_64-darwin-18 diff --git a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js index d94d6c308c..81e0171dbb 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js @@ -5,28 +5,21 @@ import loadScript from "discourse/lib/load-script"; import { makeArray } from "discourse-common/lib/helpers"; import { number } from "discourse/lib/formatter"; import { schedule } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["admin-report-chart", "admin-report-stacked-chart"], - init() { - this._super(...arguments); - - this.resizeHandler = () => - discourseDebounce(this, this._scheduleChartRendering, 500); - }, - didInsertElement() { this._super(...arguments); - $(window).on("resize.chart", this.resizeHandler); + window.addEventListener("resize", this._resizeHandler); }, willDestroyElement() { this._super(...arguments); - $(window).off("resize.chart", this.resizeHandler); - + window.removeEventListener("resize", this._resizeHandler); this._resetChart(); }, @@ -36,6 +29,11 @@ export default Component.extend({ discourseDebounce(this, this._scheduleChartRendering, 100); }, + @bind + _resizeHandler() { + discourseDebounce(this, this._scheduleChartRendering, 500); + }, + _scheduleChartRendering() { schedule("afterRender", () => { if (!this.element) { @@ -149,9 +147,7 @@ export default Component.extend({ }, _resetChart() { - if (this._chart) { - this._chart.destroy(); - this._chart = null; - } + this._chart?.destroy(); + this._chart = null; }, }); diff --git a/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js b/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js index aadf6e0f73..7ef4f7d9c1 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-table-cell.js @@ -6,6 +6,7 @@ export default Component.extend({ tagName: "td", classNames: ["admin-report-table-cell"], classNameBindings: ["type", "property"], + attributeBindings: ["value:title"], options: null, @discourseComputed("label", "data", "options") diff --git a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js index 059e138240..6e8defb93c 100644 --- a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js +++ b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js @@ -12,6 +12,10 @@ export default Component.extend({ this._super(...arguments); ajax("/admin/dashboard/new-features.json").then((json) => { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + this.setProperties({ newFeatures: json.new_features, hasUnseenFeatures: json.has_unseen_features, diff --git a/app/assets/javascripts/admin/addon/components/permalink-form.js b/app/assets/javascripts/admin/addon/components/permalink-form.js index 27846151d9..62c62bdf74 100644 --- a/app/assets/javascripts/admin/addon/components/permalink-form.js +++ b/app/assets/javascripts/admin/addon/components/permalink-form.js @@ -2,15 +2,18 @@ import Component from "@ember/component"; import I18n from "I18n"; import Permalink from "admin/models/permalink"; import bootbox from "bootbox"; -import discourseComputed from "discourse-common/utils/decorators"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; import { schedule } from "@ember/runloop"; +import { action } from "@ember/object"; export default Component.extend({ - classNames: ["permalink-form"], + tagName: "", formSubmitted: false, permalinkType: "topic_id", permalinkTypePlaceholder: fmt("permalinkType", "admin.permalink.%@"), + action: null, + permalinkTypeValue: null, @discourseComputed permalinkTypes() { @@ -23,70 +26,57 @@ export default Component.extend({ ]; }, - didInsertElement() { - this._super(...arguments); - - schedule("afterRender", () => { - $(this.element.querySelector(".external-url")).keydown((e) => { - if (e.key === "Enter") { - this.send("submit"); - } - }); - }); - }, - + @bind focusPermalink() { schedule("afterRender", () => - this.element.querySelector(".permalink-url").focus() + this.element.querySelector(".permalink-url")?.focus() ); }, - actions: { - submit() { - if (!this.formSubmitted) { - this.set("formSubmitted", true); + @action + submitFormOnEnter(event) { + if (event.key === "Enter") { + this.onSubmit(); + } + }, - Permalink.create({ - url: this.url, - permalink_type: this.permalinkType, - permalink_type_value: this.permalink_type_value, - }) - .save() - .then( - (result) => { - this.setProperties({ - url: "", - permalink_type_value: "", - formSubmitted: false, + @action + onSubmit() { + if (!this.formSubmitted) { + this.set("formSubmitted", true); + + Permalink.create({ + url: this.url, + permalink_type: this.permalinkType, + permalink_type_value: this.permalinkTypeValue, + }) + .save() + .then( + (result) => { + this.setProperties({ + url: "", + permalinkTypeValue: "", + formSubmitted: false, + }); + + this.action(Permalink.create(result.permalink)); + + this.focusPermalink(); + }, + (e) => { + this.set("formSubmitted", false); + + let error; + if (e?.jqXHR?.responseJSON?.errors) { + error = I18n.t("generic_error_with_reason", { + error: e.jqXHR.responseJSON.errors.join(". "), }); - - this.action(Permalink.create(result.permalink)); - - this.focusPermalink(); - }, - (e) => { - this.set("formSubmitted", false); - - let error; - if ( - e.jqXHR && - e.jqXHR.responseJSON && - e.jqXHR.responseJSON.errors - ) { - error = I18n.t("generic_error_with_reason", { - error: e.jqXHR.responseJSON.errors.join(". "), - }); - } else { - error = I18n.t("generic_error"); - } - bootbox.alert(error, () => this.focusPermalink()); + } else { + error = I18n.t("generic_error"); } - ); - } - }, - - onChangePermalinkType(type) { - this.set("permalinkType", type); - }, + bootbox.alert(error, this.focusPermalink); + } + ); + } }, }); diff --git a/app/assets/javascripts/admin/addon/components/staff-actions.js b/app/assets/javascripts/admin/addon/components/staff-actions.js index a532c439c2..c639a04746 100644 --- a/app/assets/javascripts/admin/addon/components/staff-actions.js +++ b/app/assets/javascripts/admin/addon/components/staff-actions.js @@ -1,39 +1,24 @@ +import { action } from "@ember/object"; import Component from "@ember/component"; import DiscourseURL from "discourse/lib/url"; export default Component.extend({ - classNames: ["table", "staff-actions"], + tagName: "", - willDestroyElement() { - $(this.element).off("click.discourse-staff-logs"); - }, + @action + openLinks(event) { + const dataset = event.target.dataset; - didInsertElement() { - this._super(...arguments); + if (dataset.linkPostId) { + event.preventDefault(); - $(this.element).on( - "click.discourse-staff-logs", - "[data-link-post-id]", - (e) => { - let postId = $(e.target).attr("data-link-post-id"); + this.store.find("post", dataset.linkPostId).then((post) => { + DiscourseURL.routeTo(post.url); + }); + } else if (dataset.linkTopicId) { + event.preventDefault(); - this.store.find("post", postId).then((p) => { - DiscourseURL.routeTo(p.get("url")); - }); - return false; - } - ); - - $(this.element).on( - "click.discourse-staff-logs", - "[data-link-topic-id]", - (e) => { - let topicId = $(e.target).attr("data-link-topic-id"); - - DiscourseURL.routeTo(`/t/${topicId}`); - - return false; - } - ); + DiscourseURL.routeTo(`/t/${dataset.linkTopicId}`); + } }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js b/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js index 7b53b2170d..c53adeecf9 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js @@ -12,9 +12,6 @@ export default Controller.extend({ uploadLabel: i18n("admin.backups.upload.label"), backupLocation: setting("backup_location"), localBackupStorage: equal("backupLocation", "local"), - enableExperimentalBackupUploader: setting( - "enable_experimental_backup_uploader" - ), @discourseComputed("status.allowRestore", "status.isOperationRunning") restoreTitle(allowRestore, isOperationRunning) { diff --git a/app/assets/javascripts/admin/addon/services/admin-tools.js b/app/assets/javascripts/admin/addon/services/admin-tools.js index fe282fefd6..a90df7de8e 100644 --- a/app/assets/javascripts/admin/addon/services/admin-tools.js +++ b/app/assets/javascripts/admin/addon/services/admin-tools.js @@ -1,8 +1,4 @@ import AdminUser from "admin/models/admin-user"; -// A service that can act as a bridge between the front end Discourse application -// and the admin application. Use this if you need front end code to access admin -// modules. Inject it optionally, and if it exists go to town! - import I18n from "I18n"; import { Promise } from "rsvp"; import Service from "@ember/service"; @@ -12,14 +8,10 @@ import { getOwner } from "discourse-common/lib/get-owner"; import { iconHTML } from "discourse-common/lib/icon-library"; import showModal from "discourse/lib/show-modal"; +// A service that can act as a bridge between the front end Discourse application +// and the admin application. Use this if you need front end code to access admin +// modules. Inject it optionally, and if it exists go to town! export default Service.extend({ - init() { - this._super(...arguments); - - // TODO: Make `siteSettings` a service that can be injected - this.siteSettings = getOwner(this).lookup("site-settings:main"); - }, - showActionLogs(target, filters) { const controller = getOwner(target).lookup( "controller:adminLogs.staffActionLogs" diff --git a/app/assets/javascripts/admin/addon/templates/backups-index.hbs b/app/assets/javascripts/admin/addon/templates/backups-index.hbs index 1a3ae7181b..da2e488425 100644 --- a/app/assets/javascripts/admin/addon/templates/backups-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/backups-index.hbs @@ -1,14 +1,18 @@
{{#if localBackupStorage}} - {{resumable-upload - target="/admin/backups/upload" - success=(route-action "uploadSuccess") - error=(route-action "uploadError") - uploadText=uploadLabel - title="admin.backups.upload.title" - class="btn-default"}} + {{#if siteSettings.enable_experimental_backup_uploader}} + {{uppy-backup-uploader done=(route-action "uploadSuccess") localBackupStorage=localBackupStorage}} + {{else}} + {{resumable-upload + target="/admin/backups/upload" + success=(route-action "uploadSuccess") + error=(route-action "uploadError") + uploadText=uploadLabel + title="admin.backups.upload.title" + class="btn-default"}} + {{/if}} {{else}} - {{#if enableExperimentalBackupUploader}} + {{#if (and siteSettings.enable_direct_s3_uploads siteSettings.enable_experimental_backup_uploader)}} {{uppy-backup-uploader done=(route-action "remoteUploadSuccess")}} {{else}} {{backup-uploader done=(route-action "remoteUploadSuccess")}} diff --git a/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs b/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs index 938c77b0d8..cbe3dc7747 100644 --- a/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/permalink-form.hbs @@ -1,35 +1,36 @@ -
- + diff --git a/app/assets/javascripts/admin/addon/templates/components/staff-actions.hbs b/app/assets/javascripts/admin/addon/templates/components/staff-actions.hbs new file mode 100644 index 0000000000..67dae94ea5 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/components/staff-actions.hbs @@ -0,0 +1,4 @@ +{{!-- template-lint-disable no-invalid-interactive --}} +
+ {{yield}} +
diff --git a/app/assets/javascripts/admin/package.json b/app/assets/javascripts/admin/package.json index 199c83d4f8..8608b42a45 100644 --- a/app/assets/javascripts/admin/package.json +++ b/app/assets/javascripts/admin/package.json @@ -46,7 +46,7 @@ "loader.js": "^4.7.0" }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" }, diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 6ddaadf6db..dbaf4acf87 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -74,7 +74,6 @@ //= require ./discourse/app/lib/link-mentions //= require ./discourse/app/components/site-header //= require ./discourse/app/components/d-editor -//= require ./discourse/app/lib/screen-track //= require ./discourse/app/routes/discourse //= require ./discourse/app/routes/build-topic-route //= require ./discourse/app/routes/restricted-user diff --git a/app/assets/javascripts/discourse-common/addon/lib/icon-library.js b/app/assets/javascripts/discourse-common/addon/lib/icon-library.js index e6c740b353..edf8160c3e 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/icon-library.js +++ b/app/assets/javascripts/discourse-common/addon/lib/icon-library.js @@ -149,7 +149,7 @@ registerIconRenderer({ if (params.label) { html += " aria-hidden='true'"; } - html += ` xmlns="${SVG_NAMESPACE}">`; + html += ` xmlns="${SVG_NAMESPACE}">`; if (params.label) { html += `${escape(params.label)}`; } @@ -178,10 +178,7 @@ registerIconRenderer({ }, [ h("use", { - "xlink:href": attributeHook( - "http://www.w3.org/1999/xlink", - `#${escape(id)}` - ), + href: attributeHook("http://www.w3.org/1999/xlink", `#${escape(id)}`), namespace: SVG_NAMESPACE, }), ] diff --git a/app/assets/javascripts/discourse-common/addon/lib/suffix-trie.js b/app/assets/javascripts/discourse-common/addon/lib/suffix-trie.js new file mode 100644 index 0000000000..a82358e4f7 --- /dev/null +++ b/app/assets/javascripts/discourse-common/addon/lib/suffix-trie.js @@ -0,0 +1,87 @@ +class TrieNode { + constructor(name, parent) { + this.name = name; + this.parent = parent; + this.children = new Map(); + this.leafIndex = null; + } +} + +// Given a set of strings, this class can allow efficient lookups +// based on suffixes. +// +// By default, it will create one Trie node per character. If your data +// has known delimiters (e.g. / in file paths), you can pass a separator +// to the constructor for better performance. +// +// Matching results will be returned in insertion order +export default class SuffixTrie { + constructor(separator = "") { + this._trie = new TrieNode(); + this.separator = separator; + this._nextIndex = 0; + } + + add(value) { + const nodeNames = value.split(this.separator); + let currentNode = this._trie; + + // Iterate over the nodes backwards. The last one should be + // at the root of the tree + for (let i = nodeNames.length - 1; i >= 0; i--) { + let newNode = currentNode.children.get(nodeNames[i]); + if (!newNode) { + newNode = new TrieNode(nodeNames[i], currentNode); + currentNode.children.set(nodeNames[i], newNode); + } + currentNode = newNode; + } + + currentNode.leafIndex = this._nextIndex++; + } + + withSuffix(suffix, resultCount = null) { + const nodeNames = suffix.split(this.separator); + + // Traverse the tree to find the root node for this suffix + let node = this._trie; + for (let i = nodeNames.length - 1; i >= 0; i--) { + node = node.children.get(nodeNames[i]); + if (!node) { + return []; + } + } + + // Find all the leaves which are descendents of that node + const leaves = []; + const descendentNodes = [node]; + while (descendentNodes.length > 0) { + const thisDescendent = descendentNodes.pop(); + if (thisDescendent.leafIndex !== null) { + leaves.push(thisDescendent); + } + descendentNodes.push(...thisDescendent.children.values()); + } + + // Sort them in-place according to insertion order + leaves.sort((a, b) => (a.leafIndex < b.leafIndex ? -1 : 1)); + + // If a subset of results have been requested, truncate + if (resultCount !== null) { + leaves.splice(resultCount); + } + + // Calculate their full names, and return the joined string + return leaves.map((leafNode) => { + const parts = [leafNode.name]; + + let ancestorNode = leafNode; + while (typeof ancestorNode.parent?.name === "string") { + parts.push(ancestorNode.parent.name); + ancestorNode = ancestorNode.parent; + } + + return parts.join(this.separator); + }); + } +} diff --git a/app/assets/javascripts/discourse-common/addon/resolver.js b/app/assets/javascripts/discourse-common/addon/resolver.js index 057f173563..e07793bfb9 100644 --- a/app/assets/javascripts/discourse-common/addon/resolver.js +++ b/app/assets/javascripts/discourse-common/addon/resolver.js @@ -2,8 +2,10 @@ import { classify, dasherize } from "@ember/string"; import deprecated from "discourse-common/lib/deprecated"; import { findHelper } from "discourse-common/lib/helpers"; import { get } from "@ember/object"; +import SuffixTrie from "discourse-common/lib/suffix-trie"; let _options = {}; +let moduleSuffixTrie = null; export function setResolverOption(name, value) { _options[name] = value; @@ -34,6 +36,18 @@ function parseName(fullName) { }; } +function lookupModuleBySuffix(suffix) { + if (!moduleSuffixTrie) { + moduleSuffixTrie = new SuffixTrie("/"); + Object.keys(requirejs.entries).forEach((name) => { + if (!name.includes("/templates/")) { + moduleSuffixTrie.add(name); + } + }); + } + return moduleSuffixTrie.withSuffix(suffix, 1)[0]; +} + export function buildResolver(baseName) { return Ember.DefaultResolver.extend({ parseName, @@ -51,7 +65,7 @@ export function buildResolver(baseName) { if (fullName === "app-events:main") { deprecated( "`app-events:main` has been replaced with `service:app-events`", - { since: "2.4.0" } + { since: "2.4.0", dropFrom: "2.9.0.beta1" } ); return "service:app-events"; } @@ -107,13 +121,7 @@ export function buildResolver(baseName) { // If we end with the name we want, use it. This allows us to define components within plugins. const suffix = parsedName.type + "s/" + parsedName.fullNameWithoutType, dashed = dasherize(suffix), - moduleName = Object.keys(requirejs.entries).find(function (e) { - return ( - e.indexOf("/templates/") === -1 && - (e.indexOf(suffix, e.length - suffix.length) !== -1 || - e.indexOf(dashed, e.length - dashed.length) !== -1) - ); - }); + moduleName = lookupModuleBySuffix(dashed); let module; if (moduleName) { diff --git a/app/assets/javascripts/discourse-common/addon/utils/category-macro.js b/app/assets/javascripts/discourse-common/addon/utils/category-macro.js new file mode 100644 index 0000000000..625cbde74d --- /dev/null +++ b/app/assets/javascripts/discourse-common/addon/utils/category-macro.js @@ -0,0 +1,8 @@ +import Category from "discourse/models/category"; +import { computed, get } from "@ember/object"; + +export default function categoryFromId(property) { + return computed(property, function () { + return Category.findById(get(this, property)); + }); +} diff --git a/app/assets/javascripts/discourse-common/addon/utils/decorators.js b/app/assets/javascripts/discourse-common/addon/utils/decorators.js index a7f1d0a53d..3439a4239f 100644 --- a/app/assets/javascripts/discourse-common/addon/utils/decorators.js +++ b/app/assets/javascripts/discourse-common/addon/utils/decorators.js @@ -1,4 +1,4 @@ -import { bind as emberBind, next, schedule } from "@ember/runloop"; +import { bind as emberBind, schedule } from "@ember/runloop"; import decoratorAlias from "discourse-common/utils/decorator-alias"; import extractValue from "discourse-common/utils/extract-value"; import handleDescriptor from "discourse-common/utils/handle-descriptor"; @@ -19,12 +19,10 @@ export default function discourseComputedDecorator(...params) { export function afterRender(target, name, descriptor) { const originalFunction = descriptor.value; descriptor.value = function () { - next(() => { - schedule("afterRender", () => { - if (this.element && !this.isDestroying && !this.isDestroyed) { - return originalFunction.apply(this, arguments); - } - }); + schedule("afterRender", () => { + if (this.element && !this.isDestroying && !this.isDestroyed) { + return originalFunction.apply(this, arguments); + } }); }; } diff --git a/app/assets/javascripts/discourse-common/package.json b/app/assets/javascripts/discourse-common/package.json index 61de38645e..ce6bb534ee 100644 --- a/app/assets/javascripts/discourse-common/package.json +++ b/app/assets/javascripts/discourse-common/package.json @@ -46,7 +46,7 @@ "loader.js": "^4.7.0" }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" }, diff --git a/app/assets/javascripts/discourse-hbr/package.json b/app/assets/javascripts/discourse-hbr/package.json index f443450f2a..0b290a071f 100644 --- a/app/assets/javascripts/discourse-hbr/package.json +++ b/app/assets/javascripts/discourse-hbr/package.json @@ -46,7 +46,7 @@ "loader.js": "^4.7.0" }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" }, diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js index 006a98ed69..5870456b45 100644 --- a/app/assets/javascripts/discourse-shims.js +++ b/app/assets/javascripts/discourse-shims.js @@ -45,3 +45,19 @@ define("@uppy/xhr-upload", ["exports"], function (__exports__) { define("@uppy/drop-target", ["exports"], function (__exports__) { __exports__.default = window.Uppy.DropTarget; }); + +define("@uppy/utils/lib/delay", ["exports"], function (__exports__) { + __exports__.default = window.Uppy.Utils.delay; +}); + +define("@uppy/utils/lib/EventTracker", ["exports"], function (__exports__) { + __exports__.default = window.Uppy.Utils.EventTracker; +}); + +define("@uppy/utils/lib/AbortController", ["exports"], function (__exports__) { + __exports__.AbortController = + window.Uppy.Utils.AbortControllerLib.AbortController; + __exports__.AbortSignal = window.Uppy.Utils.AbortControllerLib.AbortSignal; + __exports__.createAbortError = + window.Uppy.Utils.AbortControllerLib.createAbortError; +}); diff --git a/app/assets/javascripts/discourse-widget-hbs/package.json b/app/assets/javascripts/discourse-widget-hbs/package.json index 4787f2640a..06a8626fd8 100644 --- a/app/assets/javascripts/discourse-widget-hbs/package.json +++ b/app/assets/javascripts/discourse-widget-hbs/package.json @@ -46,7 +46,7 @@ "loader.js": "^4.7.0" }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" }, diff --git a/app/assets/javascripts/discourse/app/adapters/pending-post.js b/app/assets/javascripts/discourse/app/adapters/pending-post.js new file mode 100644 index 0000000000..13b086dd19 --- /dev/null +++ b/app/assets/javascripts/discourse/app/adapters/pending-post.js @@ -0,0 +1,9 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default RestAdapter.extend({ + jsonMode: true, + + pathFor(_store, _type, params) { + return `/posts/${params.username}/pending.json`; + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/composer-body.js b/app/assets/javascripts/discourse/app/components/composer-body.js index 035da9505f..4be24a8df8 100644 --- a/app/assets/javascripts/discourse/app/components/composer-body.js +++ b/app/assets/javascripts/discourse/app/components/composer-body.js @@ -11,9 +11,9 @@ import discourseDebounce from "discourse-common/lib/debounce"; import { headerHeight } from "discourse/components/site-header"; import positioningWorkaround from "discourse/lib/safari-hacks"; -const START_EVENTS = "touchstart mousedown"; -const DRAG_EVENTS = "touchmove mousemove"; -const END_EVENTS = "touchend mouseup"; +const START_DRAG_EVENTS = ["touchstart", "mousedown"]; +const DRAG_EVENTS = ["touchmove", "mousemove"]; +const END_DRAG_EVENTS = ["touchend", "mouseup"]; const THROTTLE_RATE = 20; @@ -54,17 +54,15 @@ export default Component.extend(KeyEnterEscape, { }, movePanels(size) { - $("#main-outlet").css("padding-bottom", size ? size : ""); + document.querySelector("#main-outlet").style.paddingBottom = size + ? `${size}px` + : ""; // signal the progress bar it should move! this.appEvents.trigger("composer:resized"); }, - @observes( - "composeState", - "composer.action", - "composer.canEditTopicFeaturedLink" - ) + @observes("composeState", "composer.{action,canEditTopicFeaturedLink}") resize() { schedule("afterRender", () => { if (!this.element || this.isDestroying || this.isDestroyed) { @@ -76,8 +74,11 @@ export default Component.extend(KeyEnterEscape, { }, debounceMove() { - const h = $("#reply-control:not(.saving)").height() || 0; - this.movePanels(h); + let height = 0; + if (!this.element.classList.contains("saving")) { + height = this.element.offsetHeight; + } + this.movePanels(height); }, keyUp() { @@ -105,45 +106,15 @@ export default Component.extend(KeyEnterEscape, { }, setupComposerResizeEvents() { - const $composer = $(this.element); - const $grippie = $(this.element.querySelector(".grippie")); - const $document = $(document); - let origComposerSize = 0; - let lastMousePos = 0; + this.origComposerSize = 0; + this.lastMousePos = 0; - const performDrag = (event) => { - $composer.trigger("div-resizing"); - this.appEvents.trigger("composer:div-resizing"); - $composer.addClass("clear-transitions"); - const currentMousePos = mouseYPos(event); - let size = origComposerSize + (lastMousePos - currentMousePos); - - const winHeight = $(window).height(); - size = Math.min(size, winHeight - headerHeight()); - this.movePanels(size); - $composer.height(size); - }; - - const throttledPerformDrag = ((event) => { - event.preventDefault(); - throttle(this, performDrag, event, THROTTLE_RATE); - }).bind(this); - - const endDrag = (() => { - this.appEvents.trigger("composer:resize-ended"); - $document.off(DRAG_EVENTS, throttledPerformDrag); - $document.off(END_EVENTS, endDrag); - $composer.removeClass("clear-transitions"); - $composer.focus(); - }).bind(this); - - $grippie.on(START_EVENTS, (event) => { - event.preventDefault(); - origComposerSize = $composer.height(); - lastMousePos = mouseYPos(event); - $document.on(DRAG_EVENTS, throttledPerformDrag); - $document.on(END_EVENTS, endDrag); - this.appEvents.trigger("composer:resize-started"); + START_DRAG_EVENTS.forEach((startDragEvent) => { + this.element + .querySelector(".grippie") + ?.addEventListener(startDragEvent, this.startDragHandler, { + passive: false, + }); }); if (this._visualViewportResizing()) { @@ -152,6 +123,58 @@ export default Component.extend(KeyEnterEscape, { } }, + @bind + performDragHandler() { + this.appEvents.trigger("composer:div-resizing"); + this.element.classList.add("clear-transitions"); + const currentMousePos = mouseYPos(event); + let size = this.origComposerSize + (this.lastMousePos - currentMousePos); + + size = Math.min(size, window.innerHeight - headerHeight()); + this.movePanels(size); + this.element.style.height = size ? `${size}px` : ""; + }, + + @bind + startDragHandler(event) { + event.preventDefault(); + + this.origComposerSize = this.element.offsetHeight; + this.lastMousePos = mouseYPos(event); + + DRAG_EVENTS.forEach((dragEvent) => { + document.addEventListener(dragEvent, this.throttledPerformDrag); + }); + + END_DRAG_EVENTS.forEach((endDragEvent) => { + document.addEventListener(endDragEvent, this.endDragHandler); + }); + + this.appEvents.trigger("composer:resize-started"); + }, + + @bind + endDragHandler() { + this.appEvents.trigger("composer:resize-ended"); + + DRAG_EVENTS.forEach((dragEvent) => { + document.removeEventListener(dragEvent, this.throttledPerformDrag); + }); + + END_DRAG_EVENTS.forEach((endDragEvent) => { + document.removeEventListener(endDragEvent, this.endDragHandler); + }); + + this.element.classList.remove("clear-transitions"); + this.element.focus(); + }, + + @bind + throttledPerformDrag(event) { + event.preventDefault(); + throttle(this, this.performDragHandler, event, THROTTLE_RATE); + }, + @bind viewportResize() { const composerVH = window.visualViewport.height * 0.01, @@ -207,10 +230,17 @@ export default Component.extend(KeyEnterEscape, { willDestroyElement() { this._super(...arguments); + if (this._visualViewportResizing()) { window.visualViewport.removeEventListener("resize", this.viewportResize); } + START_DRAG_EVENTS.forEach((startDragEvent) => { + this.element + .querySelector(".grippie") + ?.removeEventListener(startDragEvent, this.startDragHandler); + }); + cancel(this._lastKeyTimeout); }, diff --git a/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js b/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js deleted file mode 100644 index 1e230a3781..0000000000 --- a/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js +++ /dev/null @@ -1,14 +0,0 @@ -import ComposerEditor from "discourse/components/composer-editor"; -import { alias } from "@ember/object/computed"; -import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy"; - -export default ComposerEditor.extend(ComposerUploadUppy, { - layoutName: "components/composer-editor", - fileUploadElementId: "file-uploader", - eventPrefix: "composer", - uploadType: "composer", - uppyId: "composer-editor-uppy", - composerModel: alias("composer"), - composerModelContentKey: "reply", - editorInputClass: ".d-editor-input", -}); diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index a98df73ad1..80b9481207 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -3,6 +3,7 @@ import { authorizesAllExtensions, authorizesOneOrMoreImageExtensions, } from "discourse/lib/uploads"; +import { alias } from "@ember/object/computed"; import { BasePlugin } from "@uppy/core"; import { resolveAllShortUrls } from "pretty-text/upload-short-url"; import { @@ -12,6 +13,7 @@ import { tinyAvatar, } from "discourse/lib/utilities"; import discourseComputed, { + bind, observes, on, } from "discourse-common/utils/decorators"; @@ -26,7 +28,7 @@ import { import { later, next, schedule, throttle } from "@ember/runloop"; import Component from "@ember/component"; import Composer from "discourse/models/composer"; -import ComposerUpload from "discourse/mixins/composer-upload"; +import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy"; import EmberObject from "@ember/object"; import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; @@ -70,17 +72,6 @@ export function cleanUpComposerUploadHandler() { uploadHandlers.length = 0; } -let uploadProcessorQueue = []; -let uploadProcessorActions = {}; -export function addComposerUploadProcessor(queueItem, actionItem) { - uploadProcessorQueue.push(queueItem); - Object.assign(uploadProcessorActions, actionItem); -} -export function cleanUpComposerUploadProcessor() { - uploadProcessorQueue = []; - uploadProcessorActions = {}; -} - let uploadPreProcessors = []; export function addComposerUploadPreProcessor(pluginClass, optionsResolverFn) { if (!(pluginClass.prototype instanceof BasePlugin)) { @@ -106,18 +97,22 @@ export function cleanUpComposerUploadMarkdownResolver() { uploadMarkdownResolvers = []; } -export default Component.extend(ComposerUpload, { +export default Component.extend(ComposerUploadUppy, { classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"], fileUploadElementId: "file-uploader", mobileFileUploaderId: "mobile-file-upload", + eventPrefix: "composer", + uploadType: "composer", + uppyId: "composer-editor-uppy", + composerModel: alias("composer"), + composerModelContentKey: "reply", + editorInputClass: ".d-editor-input", shouldBuildScrollMap: true, scrollMap: null, processPreview: true, uploadMarkdownResolvers, - uploadProcessorActions, - uploadProcessorQueue, uploadPreProcessors, uploadHandlers, @@ -138,9 +133,7 @@ export default Component.extend(ComposerUpload, { @discourseComputed showLink() { - return ( - this.currentUser && this.currentUser.get("link_posting_access") !== "none" - ); + return this.currentUser && this.currentUser.link_posting_access !== "none"; }, @observes("focusTarget") @@ -189,7 +182,8 @@ export default Component.extend(ComposerUpload, { }; }, - userSearchTerm(term) { + @bind + _userSearchTerm(term) { const topicId = this.get("topic.id"); // maybe this is a brand new topic, so grab category from composer const categoryId = @@ -218,34 +212,42 @@ export default Component.extend(ComposerUpload, { return extensions.map((ext) => `.${ext}`).join(); }, + @bind + _afterMentionComplete(value) { + this.composer.set("reply", value); + + // ensures textarea scroll position is correct + schedule("afterRender", () => { + const input = this.element.querySelector(".d-editor-input"); + input?.blur(); + input?.focus(); + }); + }, + @on("didInsertElement") _composerEditorInit() { const $input = $(this.element.querySelector(".d-editor-input")); - const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); if (this.siteSettings.enable_mentions) { $input.autocomplete({ template: findRawTemplate("user-selector-autocomplete"), - dataSource: (term) => this.userSearchTerm.call(this, term), + dataSource: this._userSearchTerm, key: "@", transformComplete: (v) => v.username || v.name, - afterComplete: (value) => { - this.composer.set("reply", value); - - // ensures textarea scroll position is correct - schedule("afterRender", () => $input.blur().focus()); - }, + afterComplete: this._afterMentionComplete, triggerRule: (textarea) => !inCodeBlock(textarea.value, caretPosition(textarea)), }); } if (this._enableAdvancedEditorPreviewSync()) { - this._initInputPreviewSync($input, $preview); + const input = this.element.querySelector(".d-editor-input"); + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + this._initInputPreviewSync(input, preview); } else { - $input.on("scroll", () => - throttle(this, this._syncEditorAndPreviewScroll, $input, $preview, 20) - ); + this.element + .querySelector(".d-editor-input") + ?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll); } // Focus on the body unless we have a title @@ -316,30 +318,51 @@ export default Component.extend(ComposerUpload, { this.set("shouldBuildScrollMap", true); }, - _initInputPreviewSync($input, $preview) { + @bind + _handleInputInteraction(event) { + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + + if (!$(preview).is(":visible")) { + return; + } + + preview.removeEventListener("scroll", this._handleInputOrPreviewScroll); + event.target.addEventListener("scroll", this._handleInputOrPreviewScroll); + }, + + @bind + _handleInputOrPreviewScroll(event) { + this._syncScroll( + this._syncEditorAndPreviewScroll, + $(event.target), + $(this.element.querySelector(".d-editor-preview-wrapper")) + ); + }, + + @bind + _handlePreviewInteraction(event) { + this.element + .querySelector(".d-editor-input") + ?.removeEventListener("scroll", this._handleInputOrPreviewScroll); + + event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll); + }, + + _initInputPreviewSync(input, preview) { REBUILD_SCROLL_MAP_EVENTS.forEach((event) => { this.appEvents.on(event, this, this._resetShouldBuildScrollMap); }); schedule("afterRender", () => { - $input.on("touchstart mouseenter", () => { - if (!$preview.is(":visible")) { - return; - } - $preview.off("scroll"); - - $input.on("scroll", () => { - this._syncScroll(this._syncEditorAndPreviewScroll, $input, $preview); - }); + input?.addEventListener("touchstart", this._handleInputInteraction, { + passive: true, }); + input?.addEventListener("mouseenter", this._handleInputInteraction); - $preview.on("touchstart mouseenter", () => { - $input.off("scroll"); - - $preview.on("scroll", () => { - this._syncScroll(this._syncPreviewAndEditorScroll, $input, $preview); - }); + preview?.addEventListener("touchstart", this._handlePreviewInteraction, { + passive: true, }); + preview?.addEventListener("mouseenter", this._handlePreviewInteraction); }); }, @@ -353,13 +376,15 @@ export default Component.extend(ComposerUpload, { }, _teardownInputPreviewSync() { - [ - $(this.element.querySelector(".d-editor-input")), - $(this.element.querySelector(".d-editor-preview-wrapper")), - ].forEach(($element) => { - $element.off("mouseenter touchstart"); - $element.off("scroll"); - }); + const input = this.element.querySelector(".d-editor-input"); + input?.removeEventListener("mouseEnter", this._handleInputInteraction); + input?.removeEventListener("touchstart", this._handleInputInteraction); + input?.removeEventListener("scroll", this._handleInputOrPreviewScroll); + + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + preview?.removeEventListener("mouseEnter", this._handlePreviewInteraction); + preview?.removeEventListener("touchstart", this._handlePreviewInteraction); + preview?.removeEventListener("scroll", this._handleInputOrPreviewScroll); REBUILD_SCROLL_MAP_EVENTS.forEach((event) => { this.appEvents.off(event, this, this._resetShouldBuildScrollMap); @@ -453,6 +478,19 @@ export default Component.extend(ComposerUpload, { return scrollMap; }, + @bind + _throttledSyncEditorAndPreviewScroll(event) { + const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); + + throttle( + this, + this._syncEditorAndPreviewScroll, + $(event.target), + $preview, + 20 + ); + }, + _syncEditorAndPreviewScroll($input, $preview, scrollMap) { if (this._enableAdvancedEditorPreviewSync()) { let scrollTop; @@ -521,10 +559,11 @@ export default Component.extend(ComposerUpload, { _renderUnseenMentions(preview, unseen) { // 'Create a New Topic' scenario is not supported (per conversation with codinghorror) // https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7 - fetchUnseenMentions(unseen, this.get("composer.topic.id")).then(() => { + fetchUnseenMentions(unseen, this.get("composer.topic.id")).then((r) => { linkSeenMentions(preview, this.siteSettings); this._warnMentionedGroups(preview); this._warnCannotSeeMention(preview); + this._warnHereMention(r.here_count); }); }, @@ -599,91 +638,159 @@ export default Component.extend(ComposerUpload, { }); }, - _registerImageScaleButtonClick($preview) { - $preview.off("click", ".scale-btn").on("click", ".scale-btn", (e) => { - const index = parseInt( - $(e.target).closest(".button-wrapper").attr("data-image-index"), - 10 - ); - - const scale = e.target.attributes["data-scale"].value; - const matchingPlaceholder = this.get("composer.reply").match( - IMAGE_MARKDOWN_REGEX - ); - - if (matchingPlaceholder) { - const match = matchingPlaceholder[index]; - - if (match) { - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![$1|$2, ${scale}%$4]($5)` - ); - - this.appEvents.trigger( - "composer:replace-text", - matchingPlaceholder[index], - replacement, - { regex: IMAGE_MARKDOWN_REGEX, index } - ); - } - } - - e.preventDefault(); + _warnHereMention(hereCount) { + if (!hereCount || hereCount === 0) { return; - }); + } + + later( + this, + () => { + this.hereMention(hereCount); + }, + 2000 + ); }, - _registerImageAltTextButtonClick($preview) { - $preview - .off("click", ".alt-text-edit-btn") - .on("click", ".alt-text-edit-btn", (e) => { - const parentContainer = $(e.target).closest( - ".alt-text-readonly-container" + @bind + _handleImageScaleButtonClick(event) { + if (!event.target.classList.contains("scale-btn")) { + return; + } + + const index = parseInt( + event.target.closest(".button-wrapper").dataset.imageIndex, + 10 + ); + + const scale = event.target.dataset.scale; + const matchingPlaceholder = this.get("composer.reply").match( + IMAGE_MARKDOWN_REGEX + ); + + if (matchingPlaceholder) { + const match = matchingPlaceholder[index]; + + if (match) { + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![$1|$2, ${scale}%$4]($5)` ); - const altText = parentContainer.find(".alt-text"); - const correspondingInput = parentContainer.find(".alt-text-input"); - $(e.target).hide(); - altText.hide(); - correspondingInput.val(altText.text()); - correspondingInput.show(); - e.preventDefault(); - }); + this.appEvents.trigger( + "composer:replace-text", + matchingPlaceholder[index], + replacement, + { regex: IMAGE_MARKDOWN_REGEX, index } + ); + } + } - $preview - .off("keypress", ".alt-text-input") - .on("keypress", ".alt-text-input", (e) => { - if (e.key === "[" || e.key === "]") { - e.preventDefault(); - } + event.preventDefault(); + return; + }, - if (e.key === "Enter") { - const index = parseInt( - $(e.target).closest(".button-wrapper").attr("data-image-index"), - 10 - ); - const matchingPlaceholder = this.get("composer.reply").match( - IMAGE_MARKDOWN_REGEX - ); - const match = matchingPlaceholder[index]; - const replacement = match.replace( - IMAGE_MARKDOWN_REGEX, - `![${$(e.target).val()}|$2$3$4]($5)` - ); + resetImageControls(buttonWrapper) { + const imageResize = buttonWrapper.querySelector(".scale-btn-container"); + const readonlyContainer = buttonWrapper.querySelector( + ".alt-text-readonly-container" + ); + const editContainer = buttonWrapper.querySelector( + ".alt-text-edit-container" + ); - this.appEvents.trigger("composer:replace-text", match, replacement); + imageResize.removeAttribute("hidden"); + readonlyContainer.removeAttribute("hidden"); + buttonWrapper.removeAttribute("editing"); + editContainer.setAttribute("hidden", "true"); + }, - const parentContainer = $(e.target).closest( - ".alt-text-readonly-container" - ); - const altText = parentContainer.find(".alt-text"); - const altTextButton = parentContainer.find(".alt-text-edit-btn"); - altText.show(); - altTextButton.show(); - $(e.target).hide(); - } - }); + commitAltText(buttonWrapper) { + const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10); + const matchingPlaceholder = this.get("composer.reply").match( + IMAGE_MARKDOWN_REGEX + ); + const match = matchingPlaceholder[index]; + const input = buttonWrapper.querySelector("input.alt-text-input"); + const replacement = match.replace( + IMAGE_MARKDOWN_REGEX, + `![${input.value}|$2$3$4]($5)` + ); + + this.appEvents.trigger("composer:replace-text", match, replacement); + + this.resetImageControls(buttonWrapper); + }, + + @bind + _handleAltTextInputKeypress(event) { + if (!event.target.classList.contains("alt-text-input")) { + return; + } + + if (event.key === "[" || event.key === "]") { + event.preventDefault(); + } + + if (event.key === "Enter") { + const buttonWrapper = event.target.closest(".button-wrapper"); + this.commitAltText(buttonWrapper); + } + }, + + @bind + _handleAltTextEditButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-btn")) { + return; + } + + const buttonWrapper = event.target.closest(".button-wrapper"); + const imageResize = buttonWrapper.querySelector(".scale-btn-container"); + + const readonlyContainer = buttonWrapper.querySelector( + ".alt-text-readonly-container" + ); + const altText = readonlyContainer.querySelector(".alt-text"); + + const editContainer = buttonWrapper.querySelector( + ".alt-text-edit-container" + ); + const editContainerInput = editContainer.querySelector(".alt-text-input"); + + buttonWrapper.setAttribute("editing", "true"); + imageResize.setAttribute("hidden", "true"); + readonlyContainer.setAttribute("hidden", "true"); + editContainerInput.value = altText.textContent; + editContainer.removeAttribute("hidden"); + editContainerInput.focus(); + event.preventDefault(); + }, + + @bind + _handleAltTextOkButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-ok")) { + return; + } + + const buttonWrapper = event.target.closest(".button-wrapper"); + this.commitAltText(buttonWrapper); + }, + + @bind + _handleAltTextCancelButtonClick(event) { + if (!event.target.classList.contains("alt-text-edit-cancel")) { + return; + } + + const buttonWrapper = event.target.closest(".button-wrapper"); + this.resetImageControls(buttonWrapper); + }, + + _registerImageAltTextButtonClick(preview) { + preview.addEventListener("click", this._handleAltTextEditButtonClick); + preview.addEventListener("click", this._handleAltTextOkButtonClick); + preview.addEventListener("click", this._handleAltTextCancelButtonClick); + preview.addEventListener("keypress", this._handleAltTextInputKeypress); }, @on("willDestroyElement") @@ -701,6 +808,22 @@ export default Component.extend(ComposerUpload, { if (this._enableAdvancedEditorPreviewSync()) { this._teardownInputPreviewSync(); } + + if (!this._enableAdvancedEditorPreviewSync()) { + this.element + .querySelector(".d-editor-input") + ?.removeEventListener( + "scroll", + this._throttledSyncEditorAndPreviewScroll + ); + } + + const preview = this.element.querySelector(".d-editor-preview-wrapper"); + preview?.removeEventListener("click", this._handleImageScaleButtonClick); + preview?.removeEventListener("click", this._handleAltTextEditButtonClick); + preview?.removeEventListener("click", this._handleAltTextOkButtonClick); + preview?.removeEventListener("click", this._handleAltTextCancelButtonClick); + preview?.removeEventListener("keypress", this._handleAltTextInputKeypress); }, onExpandPopupMenuOptions(toolbarEvent) { @@ -863,8 +986,8 @@ export default Component.extend(ComposerUpload, { ); } - this._registerImageScaleButtonClick($preview); - this._registerImageAltTextButtonClick($preview); + preview.addEventListener("click", this._handleImageScaleButtonClick); + this._registerImageAltTextButtonClick(preview); this.trigger("previewRefreshed", preview); this.afterRefresh($preview); diff --git a/app/assets/javascripts/discourse/app/components/composer-messages.js b/app/assets/javascripts/discourse/app/components/composer-messages.js index a888c2829c..ad323bcb55 100644 --- a/app/assets/javascripts/discourse/app/components/composer-messages.js +++ b/app/assets/javascripts/discourse/app/components/composer-messages.js @@ -76,7 +76,7 @@ export default Component.extend({ shareModal() { const { topic } = this.composer; - const controller = showModal("share-topic"); + const controller = showModal("share-topic", { model: topic.category }); controller.setProperties({ allowInvites: topic.details.can_invite_to && diff --git a/app/assets/javascripts/discourse/app/components/cook-text.js b/app/assets/javascripts/discourse/app/components/cook-text.js index 87765cc40b..a45339bd40 100644 --- a/app/assets/javascripts/discourse/app/components/cook-text.js +++ b/app/assets/javascripts/discourse/app/components/cook-text.js @@ -1,5 +1,4 @@ import Component from "@ember/component"; -import { afterRender } from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import { cookAsync } from "discourse/lib/text"; import { loadOneboxes } from "discourse/lib/load-oneboxes"; @@ -10,31 +9,26 @@ const CookText = Component.extend({ didReceiveAttrs() { this._super(...arguments); + cookAsync(this.rawText).then((cooked) => { this.set("cooked", cooked); - if (this.paintOneboxes) { - this._loadOneboxes(); - } - this._resolveUrls(); }); }, - @afterRender - _loadOneboxes() { - const refresh = false; + didRender() { + this._super(...arguments); - loadOneboxes( - this.element, - ajax, - this.topicId, - this.categoryId, - this.siteSettings.max_oneboxes_per_post, - refresh - ); - }, + if (this.paintOneboxes) { + loadOneboxes( + this.element, + ajax, + this.topicId, + this.categoryId, + this.siteSettings.max_oneboxes_per_post, + false // refresh + ); + } - @afterRender - _resolveUrls() { resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts); }, }); diff --git a/app/assets/javascripts/discourse/app/components/create-invite-uploader.js b/app/assets/javascripts/discourse/app/components/create-invite-uploader.js index c98064d6be..17d29e01d4 100644 --- a/app/assets/javascripts/discourse/app/components/create-invite-uploader.js +++ b/app/assets/javascripts/discourse/app/components/create-invite-uploader.js @@ -1,111 +1,32 @@ import Component from "@ember/component"; -import getUrl from "discourse-common/lib/get-url"; +import { action } from "@ember/object"; +import UppyUploadMixin from "discourse/mixins/uppy-upload"; import discourseComputed from "discourse-common/utils/decorators"; -import { - displayErrorForUpload, - validateUploadedFiles, -} from "discourse/lib/uploads"; -export default Component.extend({ - tagName: "", +export default Component.extend(UppyUploadMixin, { + id: "create-invite-uploader", + tagName: "div", + type: "csv", + autoStartUploads: false, + uploadUrl: "/invites/upload_csv", + preventDirectS3Uploads: true, + fileInputSelector: "#csv-file", - data: null, - uploading: false, - progress: 0, - uploaded: null, - - @discourseComputed("messageBus.clientId") - clientId() { - return this.messageBus && this.messageBus.clientId; + validateUploadedFilesOptions() { + return { bypassNewUserRestriction: true, csvOnly: true }; }, - @discourseComputed("data", "uploading") - submitDisabled(data, uploading) { - return !data || uploading; + @discourseComputed("filesAwaitingUpload", "uploading") + submitDisabled(filesAwaitingUpload, uploading) { + return !filesAwaitingUpload || uploading; }, - didInsertElement() { - this._super(...arguments); - - this.setProperties({ - data: null, - uploading: false, - progress: 0, - uploaded: null, - }); - - const $upload = $("#csv-file"); - - $upload.fileupload({ - url: getUrl("/invites/upload_csv.json") + "?client_id=" + this.clientId, - dataType: "json", - dropZone: null, - replaceFileInput: false, - autoUpload: false, - }); - - $upload.on("fileuploadadd", (e, data) => { - this.set("data", data); - }); - - $upload.on("fileuploadsubmit", (e, data) => { - const isValid = validateUploadedFiles(data.files, { - user: this.currentUser, - siteSettings: this.siteSettings, - bypassNewUserRestriction: true, - csvOnly: true, - }); - - data.formData = { type: "csv" }; - this.setProperties({ progress: 0, uploading: isValid }); - - return isValid; - }); - - $upload.on("fileuploadprogress", (e, data) => { - const progress = parseInt((data.loaded / data.total) * 100, 10); - this.set("progress", progress); - }); - - $upload.on("fileuploaddone", (e, data) => { - const upload = data.result; - this.set("uploaded", upload); - this.reset(); - }); - - $upload.on("fileuploadfail", (e, data) => { - if (data.errorThrown !== "abort") { - displayErrorForUpload(data, this.siteSettings, data.files[0].name); - } - this.reset(); - }); + uploadDone() { + this.set("uploaded", true); }, - willDestroyElement() { - this._super(...arguments); - - if (this.messageBus) { - this.messageBus.unsubscribe("/uploads/csv"); - } - - const $upload = $(this.element); - - try { - $upload.fileupload("destroy"); - } catch (e) { - /* wasn't initialized yet */ - } finally { - $upload.off(); - } - }, - - reset() { - this.setProperties({ - data: null, - uploading: false, - progress: 0, - }); - - document.getElementById("csv-file").value = ""; + @action + startUpload() { + this._startUpload(); }, }); diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 2c64f01570..1b0ce15734 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -5,6 +5,7 @@ import { translateModKey, } from "discourse/lib/utilities"; import discourseComputed, { + bind, observes, on, } from "discourse-common/utils/decorators"; @@ -21,7 +22,6 @@ import deprecated from "discourse-common/lib/deprecated"; import discourseDebounce from "discourse-common/lib/debounce"; import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { getRegister } from "discourse-common/lib/get-owner"; -import { isEmpty } from "@ember/utils"; import { isTesting } from "discourse-common/config/environment"; import { linkSeenHashtags } from "discourse/lib/link-hashtags"; import { linkSeenMentions } from "discourse/lib/link-mentions"; @@ -286,31 +286,9 @@ export default Component.extend(TextareaTextManipulation, { }); // disable clicking on links in the preview - $(this.element.querySelector(".d-editor-preview")).on( - "click.preview", - (e) => { - if (wantsNewWindow(e)) { - return; - } - const $target = $(e.target); - if ($target.is("a.mention")) { - this.appEvents.trigger( - "click.discourse-preview-user-card-mention", - $target - ); - } - if ($target.is("a.mention-group")) { - this.appEvents.trigger( - "click.discourse-preview-group-card-mention-group", - $target - ); - } - if ($target.is("a")) { - e.preventDefault(); - return false; - } - } - ); + this.element + .querySelector(".d-editor-preview") + .addEventListener("click", this._handlePreviewLinkClick); if (this.composerEvents) { this.appEvents.on("composer:insert-block", this, "_insertBlock"); @@ -323,6 +301,32 @@ export default Component.extend(TextareaTextManipulation, { } }, + @bind + _handlePreviewLinkClick(event) { + if (wantsNewWindow(event)) { + return; + } + + if (event.target.tagName === "A") { + if (event.target.classList.contains("mention")) { + this.appEvents.trigger( + "click.discourse-preview-user-card-mention", + $(event.target) + ); + } + + if (event.target.classList.contains("mention-group")) { + this.appEvents.trigger( + "click.discourse-preview-group-card-mention-group", + $(event.target) + ); + } + + event.preventDefault(); + return false; + } + }, + @on("willDestroyElement") _shutDown() { if (this.composerEvents) { @@ -334,7 +338,9 @@ export default Component.extend(TextareaTextManipulation, { this._itsatrap?.destroy(); this._itsatrap = null; - $(this.element.querySelector(".d-editor-preview")).off("click.preview"); + this.element + .querySelector(".d-editor-preview") + ?.removeEventListener("click", this._handlePreviewLinkClick); this._previewMutationObserver?.disconnect(); @@ -787,28 +793,6 @@ export default Component.extend(TextareaTextManipulation, { this.set("emojiPickerIsActive", !this.emojiPickerIsActive); }, - emojiSelected(code) { - let selected = this._getSelected(); - const captures = selected.pre.match(/\B:(\w*)$/); - - if (isEmpty(captures)) { - if (selected.pre.match(/\S$/)) { - this._addText(selected, ` :${code}:`); - } else { - this._addText(selected, `:${code}:`); - } - } else { - let numOfRemovedChars = selected.pre.length - captures[1].length; - selected.pre = selected.pre.slice( - 0, - selected.pre.length - captures[1].length - ); - selected.start -= numOfRemovedChars; - selected.end -= numOfRemovedChars; - this._addText(selected, `${code}:`); - } - }, - toolbarButton(button) { if (this.disabled) { return; diff --git a/app/assets/javascripts/discourse/app/components/d-navigation.js b/app/assets/javascripts/discourse/app/components/d-navigation.js index 6d00181e9e..cc20ce09d1 100644 --- a/app/assets/javascripts/discourse/app/components/d-navigation.js +++ b/app/assets/javascripts/discourse/app/components/d-navigation.js @@ -59,11 +59,13 @@ export default Component.extend(FilterModeMixin, { @discourseComputed("categoryReadOnlyBanner", "hasDraft") createTopicClass(categoryReadOnlyBanner, hasDraft) { - if (categoryReadOnlyBanner && !hasDraft) { - return "btn-default disabled"; - } else { - return "btn-default"; + let classNames = ["btn-default"]; + if (hasDraft) { + classNames.push("open-draft"); + } else if (categoryReadOnlyBanner) { + classNames.push("disabled"); } + return classNames.join(" "); }, @discourseComputed("hasDraft") diff --git a/app/assets/javascripts/discourse/app/components/discourse-topic.js b/app/assets/javascripts/discourse/app/components/discourse-topic.js index 784c8fdb8d..d5daaca839 100644 --- a/app/assets/javascripts/discourse/app/components/discourse-topic.js +++ b/app/assets/javascripts/discourse/app/components/discourse-topic.js @@ -7,7 +7,7 @@ import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction"; import Scrolling from "discourse/mixins/scrolling"; import { alias } from "@ember/object/computed"; import { highlightPost } from "discourse/lib/utilities"; -import { observes } from "discourse-common/utils/decorators"; +import { bind, observes } from "discourse-common/utils/decorators"; const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300; @@ -40,12 +40,11 @@ export default Component.extend( _enteredTopic() { // Ember is supposed to only call observers when values change but something // in our view set up is firing this observer with the same value. This check - // prevents scrolled from being called twice. - const enteredAt = this.enteredAt; - if (enteredAt && this.lastEnteredAt !== enteredAt) { + // prevents scrolled from being called twice + if (this.enteredAt && this.lastEnteredAt !== this.enteredAt) { this._lastShowTopic = null; - schedule("afterRender", () => this.scrolled()); - this.set("lastEnteredAt", enteredAt); + schedule("afterRender", this.scrolled); + this.set("lastEnteredAt", this.enteredAt); } }, @@ -83,7 +82,7 @@ export default Component.extend( return; } - const offset = window.pageYOffset || $("html").scrollTop(); + const offset = window.pageYOffset || document.documentElement.scrollTop; this._lastShowTopic = this.shouldShowTopicInHeader(topic, offset); if (this._lastShowTopic) { @@ -95,16 +94,14 @@ export default Component.extend( didInsertElement() { this._super(...arguments); - this.bindScrolling({ name: "topic-view" }); - - $(window).on("resize.discourse-on-scroll", () => this.scrolled()); + this.bindScrolling(); + window.addEventListener("resize", this.scrolled); $(this.element).on( "click.discourse-redirect", ".cooked a, a.track-link", (e) => ClickTrack.trackClick(e, this.siteSettings) ); - this.appEvents.on("discourse:focus-changed", this, "gotFocus"); this.appEvents.on("post:highlight", this, "_highlightPost"); this.appEvents.on("header:update-topic", this, "_updateTopic"); @@ -112,8 +109,9 @@ export default Component.extend( willDestroyElement() { this._super(...arguments); - this.unbindScrolling("topic-view"); - $(window).unbind("resize.discourse-on-scroll"); + + this.unbindScrolling(); + window.removeEventListener("resize", this.scrolled); // Unbind link tracking $(this.element).off( @@ -149,31 +147,36 @@ export default Component.extend( (!this.site.mobileView || this.mobileScrollDirection === "down") ); }, + // The user has scrolled the window, or it is finished rendering and ready for processing. + @bind scrolled() { if (this.isDestroyed || this.isDestroying || this._state !== "inDOM") { return; } - const offset = window.pageYOffset || $("html").scrollTop(); + const offset = window.pageYOffset || document.documentElement.scrollTop; if (this.dockAt === 0) { - const title = $("#topic-title"); - if (title && title.length === 1) { - this.set("dockAt", title.offset().top); + const title = document.querySelector("#topic-title"); + if (title) { + this.set( + "dockAt", + title.getBoundingClientRect().top + window.scrollY + ); } } this.set("hasScrolled", offset > 0); - const topic = this.topic; - const showTopic = this.shouldShowTopicInHeader(topic, offset); + const showTopic = this.shouldShowTopicInHeader(this.topic, offset); if (showTopic !== this._lastShowTopic) { if (showTopic) { - this._showTopicInHeader(topic); + this._showTopicInHeader(this.topic); } else { if (!DiscourseURL.isJumpScheduled()) { - const loadingNear = topic.get("postStream.loadingNearPost") || 1; + const loadingNear = + this.topic.get("postStream.loadingNearPost") || 1; if (loadingNear === 1) { this._hideTopicInHeader(); } diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js index 2018919644..3d04b226bb 100644 --- a/app/assets/javascripts/discourse/app/components/emoji-picker.js +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js @@ -6,7 +6,7 @@ import { isSkinTonableEmoji, } from "pretty-text/emoji"; import { emojiUnescape, emojiUrlFor } from "discourse/lib/text"; -import { escapeExpression, safariHacksDisabled } from "discourse/lib/utilities"; +import { escapeExpression } from "discourse/lib/utilities"; import { later, schedule } from "@ember/runloop"; import Component from "@ember/component"; import { createPopper } from "@popperjs/core"; @@ -115,10 +115,7 @@ export default Component.extend({ this.set("isLoading", false); schedule("afterRender", () => { - if ( - (!this.site.isMobileDevice || this.isEditorFocused) && - !safariHacksDisabled() - ) { + if (!this.site.isMobileDevice || this.isEditorFocused) { const filter = emojiPicker.querySelector("input.filter"); filter && filter.focus(); diff --git a/app/assets/javascripts/discourse/app/components/footer-nav.js b/app/assets/javascripts/discourse/app/components/footer-nav.js index 449ab625e4..5df81b7bc7 100644 --- a/app/assets/javascripts/discourse/app/components/footer-nav.js +++ b/app/assets/javascripts/discourse/app/components/footer-nav.js @@ -40,7 +40,7 @@ const FooterNavComponent = MountWidget.extend( if (this.capabilities.isIpadOS) { document.body.classList.add("footer-nav-ipad"); } else { - this.bindScrolling({ name: "footer-nav" }); + this.bindScrolling(); window.addEventListener("resize", this.scrolled, false); this.appEvents.on("composer:opened", this, "_composerOpened"); this.appEvents.on("composer:closed", this, "_composerClosed"); @@ -60,7 +60,7 @@ const FooterNavComponent = MountWidget.extend( if (this.capabilities.isIpadOS) { document.body.classList.remove("footer-nav-ipad"); } else { - this.unbindScrolling("footer-nav"); + this.unbindScrolling(); window.removeEventListener("resize", this.scrolled); this.appEvents.off("composer:opened", this, "_composerOpened"); this.appEvents.off("composer:closed", this, "_composerClosed"); diff --git a/app/assets/javascripts/discourse/app/components/future-date-input.js b/app/assets/javascripts/discourse/app/components/future-date-input.js index f673f73ae5..3f17a595ec 100644 --- a/app/assets/javascripts/discourse/app/components/future-date-input.js +++ b/app/assets/javascripts/discourse/app/components/future-date-input.js @@ -1,8 +1,10 @@ -import { and, empty, equal } from "@ember/object/computed"; -import { action } from "@ember/object"; import Component from "@ember/component"; -import { FORMAT } from "select-kit/components/future-date-input-selector"; +import { action } from "@ember/object"; +import { and, empty, equal } from "@ember/object/computed"; +import { CLOSE_STATUS_TYPE } from "discourse/controllers/edit-topic-timer"; +import buildTimeframes from "discourse/lib/timeframes-builder"; import I18n from "I18n"; +import { FORMAT } from "select-kit/components/future-date-input-selector"; export default Component.extend({ selection: null, @@ -20,12 +22,17 @@ export default Component.extend({ this._super(...arguments); if (this.input) { - const datetime = moment(this.input); - this.setProperties({ - selection: "pick_date_and_time", - _date: datetime.format("YYYY-MM-DD"), - _time: datetime.format("HH:mm"), - }); + const dateTime = moment(this.input); + const closestTimeframe = this.findClosestTimeframe(dateTime); + if (closestTimeframe) { + this.set("selection", closestTimeframe.id); + } else { + this.setProperties({ + selection: "pick_date_and_time", + _date: dateTime.format("YYYY-MM-DD"), + _time: dateTime.format("HH:mm"), + }); + } } }, @@ -64,4 +71,31 @@ export default Component.extend({ this.attrs.onChangeInput && this.attrs.onChangeInput(null); } }, + + findClosestTimeframe(dateTime) { + const now = moment(); + + const futureDateInputSelectorOptions = { + now, + day: now.day(), + includeWeekend: this.includeWeekend, + includeMidFuture: this.includeMidFuture || true, + includeFarFuture: this.includeFarFuture, + includeDateTime: this.includeDateTime, + canScheduleNow: this.includeNow || false, + canScheduleToday: 24 - now.hour() > 6, + }; + + return buildTimeframes(futureDateInputSelectorOptions).find((tf) => { + const tfDateTime = tf.when( + moment(), + this.statusType !== CLOSE_STATUS_TYPE ? 8 : 18 + ); + + if (tfDateTime) { + const diff = tfDateTime.diff(dateTime); + return 0 <= diff && diff < 60 * 1000; + } + }); + }, }); diff --git a/app/assets/javascripts/discourse/app/components/global-notice.js b/app/assets/javascripts/discourse/app/components/global-notice.js index 82936c7a59..d37c3c0b82 100644 --- a/app/assets/javascripts/discourse/app/components/global-notice.js +++ b/app/assets/javascripts/discourse/app/components/global-notice.js @@ -1,11 +1,11 @@ -import EmberObject, { computed } from "@ember/object"; +import EmberObject, { action } from "@ember/object"; import cookie, { removeCookie } from "discourse/lib/cookie"; import Component from "@ember/component"; import I18n from "I18n"; -import LogsNotice from "discourse/services/logs-notice"; -import { bind } from "discourse-common/utils/decorators"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; import getURL from "discourse-common/lib/get-url"; import { htmlSafe } from "@ember/template"; +import { inject as service } from "@ember/service"; const _pluginNotices = []; @@ -16,6 +16,8 @@ export function addGlobalNotice(text, id, options = {}) { const GLOBAL_NOTICE_DISMISSED_PROMPT_KEY = "dismissed-global-notice-v2"; const Notice = EmberObject.extend({ + logsNoticeService: service("logsNotice"), + text: null, id: null, options: null, @@ -48,186 +50,190 @@ const Notice = EmberObject.extend({ }); export default Component.extend({ + logsNoticeService: service("logsNotice"), logNotice: null, init() { this._super(...arguments); - this._setupObservers(); + this.logsNoticeService.addObserver("hidden", this._handleLogsNoticeUpdate); + this.logsNoticeService.addObserver("text", this._handleLogsNoticeUpdate); }, willDestroyElement() { this._super(...arguments); - this._tearDownObservers(); + this.logsNoticeService.removeObserver("text", this._handleLogsNoticeUpdate); + this.logsNoticeService.removeObserver( + "hidden", + this._handleLogsNoticeUpdate + ); }, - notices: computed( + @discourseComputed( "site.isReadOnly", + "site.wizard_required", + "siteSettings.login_required", "siteSettings.disable_emails", - "logNotice.{id,text,hidden}", - function () { - let notices = []; + "siteSettings.global_notice", + "siteSettings.bootstrap_mode_enabled", + "siteSettings.bootstrap_mode_min_users", + "session.safe_mode", + "logNotice.{id,text,hidden}" + ) + notices( + isReadOnly, + wizardRequired, + loginRequired, + disableEmails, + globalNotice, + bootstrapModeEnabled, + bootstrapModeMinUsers, + safeMode, + logNotice + ) { + let notices = []; - if (cookie("dosp") === "1") { - removeCookie("dosp", { path: "/" }); + if (cookie("dosp") === "1") { + removeCookie("dosp", { path: "/" }); + notices.push( + Notice.create({ + text: loginRequired + ? I18n.t("forced_anonymous_login_required") + : I18n.t("forced_anonymous"), + id: "forced-anonymous", + }) + ); + } + + if (safeMode) { + notices.push( + Notice.create({ text: I18n.t("safe_mode.enabled"), id: "safe-mode" }) + ); + } + + if (isReadOnly) { + notices.push( + Notice.create({ + text: I18n.t("read_only_mode.enabled"), + id: "alert-read-only", + }) + ); + } + + if (disableEmails === "yes" || disableEmails === "non-staff") { + notices.push( + Notice.create({ + text: I18n.t("emails_are_disabled"), + id: "alert-emails-disabled", + }) + ); + } + + if (wizardRequired) { + const requiredText = I18n.t("wizard_required", { + url: getURL("/wizard"), + }); + notices.push( + Notice.create({ text: htmlSafe(requiredText), id: "alert-wizard" }) + ); + } + + if (this.currentUser?.staff && bootstrapModeEnabled) { + if (bootstrapModeMinUsers > 0) { notices.push( Notice.create({ - text: this.siteSettings.login_required - ? I18n.t("forced_anonymous_login_required") - : I18n.t("forced_anonymous"), - id: "forced-anonymous", + text: I18n.t("bootstrap_mode_enabled", { + count: bootstrapModeMinUsers, + }), + id: "alert-bootstrap-mode", + }) + ); + } else { + notices.push( + Notice.create({ + text: I18n.t("bootstrap_mode_disabled"), + id: "alert-bootstrap-mode", }) ); } + } - if (this.session && this.session.safe_mode) { - notices.push( - Notice.create({ text: I18n.t("safe_mode.enabled"), id: "safe-mode" }) - ); + if (globalNotice?.length > 0) { + notices.push( + Notice.create({ + text: globalNotice, + id: "alert-global-notice", + }) + ); + } + + if (logNotice) { + notices.push(logNotice); + } + + return notices.concat(_pluginNotices).filter((notice) => { + if (notice.options.visibility) { + return notice.options.visibility(notice); } - if (this.site.isReadOnly) { - notices.push( - Notice.create({ - text: I18n.t("read_only_mode.enabled"), - id: "alert-read-only", - }) - ); + const key = `${GLOBAL_NOTICE_DISMISSED_PROMPT_KEY}-${notice.id}`; + const value = this.keyValueStore.get(key); + + // banner has never been dismissed + if (!value) { + return true; } - if ( - this.siteSettings.disable_emails === "yes" || - this.siteSettings.disable_emails === "non-staff" - ) { - notices.push( - Notice.create({ - text: I18n.t("emails_are_disabled"), - id: "alert-emails-disabled", - }) - ); + // banner has no persistent dismiss and should always show on load + if (!notice.options.persistentDismiss) { + return true; } - if (this.site.wizard_required) { - const requiredText = I18n.t("wizard_required", { - url: getURL("/wizard"), - }); - notices.push( - Notice.create({ text: htmlSafe(requiredText), id: "alert-wizard" }) - ); + if (notice.options.dismissDuration) { + const resetAt = moment(value).add(notice.options.dismissDuration); + return moment().isAfter(resetAt); + } else { + return false; } + }); + }, - if ( - this.get("currentUser.staff") && - this.siteSettings.bootstrap_mode_enabled - ) { - if (this.siteSettings.bootstrap_mode_min_users > 0) { - notices.push( - Notice.create({ - text: I18n.t("bootstrap_mode_enabled", { - count: this.siteSettings.bootstrap_mode_min_users, - }), - id: "alert-bootstrap-mode", - }) - ); - } else { - notices.push( - Notice.create({ - text: I18n.t("bootstrap_mode_disabled"), - id: "alert-bootstrap-mode", - }) - ); - } - } + @action + dismissNotice(notice) { + if (notice.options.onDismiss) { + notice.options.onDismiss(notice); + } - if ( - this.siteSettings.global_notice && - this.siteSettings.global_notice.length - ) { - notices.push( - Notice.create({ - text: this.siteSettings.global_notice, - id: "alert-global-notice", - }) - ); - } - - if (this.logNotice) { - notices.push(this.logNotice); - } - - return notices.concat(_pluginNotices).filter((notice) => { - if (notice.options.visibility) { - return notice.options.visibility(notice); - } else { - const key = `${GLOBAL_NOTICE_DISMISSED_PROMPT_KEY}-${notice.id}`; - const value = this.keyValueStore.get(key); - - // banner has never been dismissed - if (!value) { - return true; - } - - // banner has no persistent dismiss and should always show on load - if (!notice.options.persistentDismiss) { - return true; - } - - if (notice.options.dismissDuration) { - const resetAt = moment(value).add(notice.options.dismissDuration); - return moment().isAfter(resetAt); - } else { - return false; - } - } + if (notice.options.persistentDismiss) { + this.keyValueStore.set({ + key: `${GLOBAL_NOTICE_DISMISSED_PROMPT_KEY}-${notice.id}`, + value: moment().toISOString(true), }); } - ), - actions: { - dismissNotice(notice) { - if (notice.options.onDismiss) { - notice.options.onDismiss(notice); - } - - if (notice.options.persistentDismiss) { - this.keyValueStore.set({ - key: `${GLOBAL_NOTICE_DISMISSED_PROMPT_KEY}-${notice.id}`, - value: moment().toISOString(true), - }); - } - - const alert = document.getElementById(`global-notice-${notice.id}`); - if (alert) { - alert.style.display = "none"; - } - }, - }, - - _setupObservers() { - LogsNotice.current().addObserver("hidden", this._handleLogsNoticeUpdate); - LogsNotice.current().addObserver("text", this._handleLogsNoticeUpdate); - }, - - _tearDownObservers() { - LogsNotice.current().removeObserver("text", this._handleLogsNoticeUpdate); - LogsNotice.current().removeObserver("hidden", this._handleLogsNoticeUpdate); + const alert = document.getElementById(`global-notice-${notice.id}`); + if (alert) { + alert.style.display = "none"; + } }, @bind _handleLogsNoticeUpdate() { const logNotice = Notice.create({ - text: htmlSafe(LogsNotice.currentProp("message")), + text: htmlSafe(this.logsNoticeService.message), id: "alert-logs-notice", options: { dismissable: true, persistentDismiss: false, visibility() { - return !LogsNotice.currentProp("hidden"); + return !this.logsNoticeService.hidden; }, onDismiss() { - LogsNotice.currentProp("hidden", true); - LogsNotice.currentProp("text", ""); + this.logsNoticeService.setProperties({ + hidden: true, + text: "", + }); }, }, }); diff --git a/app/assets/javascripts/discourse/app/components/html-with-links.js b/app/assets/javascripts/discourse/app/components/html-with-links.js index d14b7dc7c7..a49b1dfcc9 100644 --- a/app/assets/javascripts/discourse/app/components/html-with-links.js +++ b/app/assets/javascripts/discourse/app/components/html-with-links.js @@ -5,19 +5,11 @@ import { import Component from "@ember/component"; export default Component.extend({ - didInsertElement() { - this._super(...arguments); - $(this.element).on("click.discourse-open-tab", "a", (event) => { - if (event.target && event.target.tagName === "A") { - if (shouldOpenInNewTab(event.target.href)) { - openLinkInNewTab(event.target); - } + click(event) { + if (event?.target?.tagName === "A") { + if (shouldOpenInNewTab(event.target.href)) { + openLinkInNewTab(event.target); } - }); - }, - - willDestroyElement() { - this._super(...arguments); - $(this.element).off("click.discourse-open-tab", "a"); + } }, }); diff --git a/app/assets/javascripts/discourse/app/components/links-redirect.js b/app/assets/javascripts/discourse/app/components/links-redirect.js index 45996d88ec..c3e5369313 100644 --- a/app/assets/javascripts/discourse/app/components/links-redirect.js +++ b/app/assets/javascripts/discourse/app/components/links-redirect.js @@ -2,16 +2,9 @@ import ClickTrack from "discourse/lib/click-track"; import Component from "@ember/component"; export default Component.extend({ - didInsertElement() { - this._super(...arguments); - - $(this.element).on("click.discourse-redirect", "a", (e) => { - return ClickTrack.trackClick(e, this.siteSettings); - }); - }, - - willDestroyElement() { - this._super(...arguments); - $(this.element).off("click.discourse-redirect", "a"); + click(event) { + if (event?.target?.tagName === "A") { + return ClickTrack.trackClick(event, this.siteSettings); + } }, }); diff --git a/app/assets/javascripts/discourse/app/components/mobile-nav.js b/app/assets/javascripts/discourse/app/components/mobile-nav.js index 8648020a20..bdbb2cc85a 100644 --- a/app/assets/javascripts/discourse/app/components/mobile-nav.js +++ b/app/assets/javascripts/discourse/app/components/mobile-nav.js @@ -17,6 +17,7 @@ export default Component.extend({ if (this.currentPath) { deprecated("{{mobile-nav}} no longer requires the currentPath property", { since: "2.7.0.beta4", + dropFrom: "2.9.0.beta1", }); } }, diff --git a/app/assets/javascripts/discourse/app/components/pending-post.js b/app/assets/javascripts/discourse/app/components/pending-post.js new file mode 100644 index 0000000000..428ff0416c --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/pending-post.js @@ -0,0 +1,29 @@ +import Component from "@ember/component"; +import { afterRender } from "discourse-common/utils/decorators"; +import { loadOneboxes } from "discourse/lib/load-oneboxes"; +import { ajax } from "discourse/lib/ajax"; +import { resolveAllShortUrls } from "pretty-text/upload-short-url"; + +export default Component.extend({ + didRender() { + this._loadOneboxes(); + this._resolveUrls(); + }, + + @afterRender + _loadOneboxes() { + loadOneboxes( + this.element, + ajax, + this.post.topic_id, + this.post.category_id, + this.siteSettings.max_oneboxes_per_post, + true + ); + }, + + @afterRender + _resolveUrls() { + resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts); + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/scroll-tracker.js b/app/assets/javascripts/discourse/app/components/scroll-tracker.js index 228a50d1a2..9e961b2892 100644 --- a/app/assets/javascripts/discourse/app/components/scroll-tracker.js +++ b/app/assets/javascripts/discourse/app/components/scroll-tracker.js @@ -12,7 +12,7 @@ export default Component.extend(Scrolling, { didInsertElement() { this._super(...arguments); - this.bindScrolling({ name: this.name }); + this.bindScrolling(); }, didRender() { @@ -27,7 +27,7 @@ export default Component.extend(Scrolling, { willDestroyElement() { this._super(...arguments); - this.unbindScrolling(this.name); + this.unbindScrolling(); }, scrolled() { diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js index 8234c21b36..98ff4d775f 100644 --- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js +++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js @@ -6,6 +6,7 @@ import discourseDebounce from "discourse-common/lib/debounce"; import { isWorkaroundActive } from "discourse/lib/safari-hacks"; import offsetCalculator from "discourse/lib/offset-calculator"; import { inject as service } from "@ember/service"; +import { bind } from "discourse-common/utils/decorators"; const DEBOUNCE_DELAY = 50; @@ -320,19 +321,21 @@ export default MountWidget.extend({ this.queueRerender(); }, + @bind _debouncedScroll() { discourseDebounce(this, this._scrollTriggered, DEBOUNCE_DELAY); }, didInsertElement() { this._super(...arguments); - const debouncedScroll = () => - discourseDebounce(this, this._scrollTriggered, DEBOUNCE_DELAY); this._previouslyNearby = {}; this.appEvents.on("post-stream:refresh", this, "_debouncedScroll"); - $(document).bind("touchmove.post-stream", debouncedScroll); - $(window).bind("scroll.post-stream", debouncedScroll); + const opts = { + passive: true, + }; + document.addEventListener("touchmove", this._debouncedScroll, opts); + window.addEventListener("scroll", this._debouncedScroll, opts); this._scrollTriggered(); this.appEvents.on("post-stream:posted", this, "_posted"); @@ -362,8 +365,8 @@ export default MountWidget.extend({ willDestroyElement() { this._super(...arguments); - $(document).unbind("touchmove.post-stream"); - $(window).unbind("scroll.post-stream"); + document.removeEventListener("touchmove", this._debouncedScroll); + window.removeEventListener("scroll", this._debouncedScroll); this.appEvents.off("post-stream:refresh", this, "_debouncedScroll"); $(this.element).off("mouseenter.post-stream"); $(this.element).off("mouseleave.post-stream"); diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index 3d65573877..32045dec66 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -186,18 +186,23 @@ const SiteHeaderComponent = MountWidget.extend( const headerRect = header.getBoundingClientRect(), headerOffset = headerRect.top + headerRect.height, doc = document.documentElement; + + const newValue = `${headerOffset}px`; + if (newValue !== this.currentHeaderOffsetValue) { + this.currentHeaderOffsetValue = newValue; + doc.style.setProperty("--header-offset", newValue); + } + if (offset >= this.docAt) { if (!this.dockedHeader) { document.body.classList.add("docked"); this.dockedHeader = true; - doc.style.setProperty("--header-offset", `${headerOffset}px`); } } else { if (this.dockedHeader) { document.body.classList.remove("docked"); this.dockedHeader = false; } - doc.style.setProperty("--header-offset", `${headerOffset}px`); } }, diff --git a/app/assets/javascripts/discourse/app/components/tag-info.js b/app/assets/javascripts/discourse/app/components/tag-info.js index 5749eb283e..60ddb3ba60 100644 --- a/app/assets/javascripts/discourse/app/components/tag-info.js +++ b/app/assets/javascripts/discourse/app/components/tag-info.js @@ -6,7 +6,7 @@ import bootbox from "bootbox"; import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import showModal from "discourse/lib/show-modal"; +import { inject as service } from "@ember/service"; export default Component.extend({ tagName: "", @@ -16,6 +16,10 @@ export default Component.extend({ showEditControls: false, canAdminTag: reads("currentUser.staff"), editSynonymsMode: and("canAdminTag", "showEditControls"), + editing: false, + newTagName: null, + newTagDescription: null, + router: service(), @discourseComputed("tagInfo.tag_group_names") tagGroupsInfo(tagGroupNames) { @@ -41,6 +45,13 @@ export default Component.extend({ return isEmpty(tagGroupNames) && isEmpty(categories) && isEmpty(synonyms); }, + @discourseComputed("newTagName") + updateDisabled(newTagName) { + const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); + newTagName = newTagName ? newTagName.replace(filterRegexp, "").trim() : ""; + return newTagName.length === 0; + }, + didInsertElement() { this._super(...arguments); this.loadTagInfo(); @@ -69,8 +80,29 @@ export default Component.extend({ this.toggleProperty("showEditControls"); }, - renameTag() { - showModal("rename-tag", { model: this.tag }); + edit() { + this.setProperties({ + editing: true, + newTagName: this.tag.id, + newTagDescription: this.tagInfo.description, + }); + }, + + cancelEditing() { + this.set("editing", false); + }, + + finishedEditing() { + this.tag + .update({ id: this.newTagName, description: this.newTagDescription }) + .then((result) => { + this.set("editing", false); + this.tagInfo.set("description", this.newTagDescription); + if (result.payload) { + this.router.transitionTo("tag.show", result.payload.id); + } + }) + .catch(popupAjaxError); }, deleteTag() { diff --git a/app/assets/javascripts/discourse/app/components/topic-timer-info.js b/app/assets/javascripts/discourse/app/components/topic-timer-info.js index 0b467f6395..1589a8dd24 100644 --- a/app/assets/javascripts/discourse/app/components/topic-timer-info.js +++ b/app/assets/javascripts/discourse/app/components/topic-timer-info.js @@ -75,7 +75,7 @@ export default Component.extend({ const duration = moment.duration(statusUpdateAt - moment()); const minutesLeft = duration.asMinutes(); if (minutesLeft > 0 || isDeleteRepliesType || this.basedOnLastPost) { - let durationMinutes = parseInt(this.durationMinutes, 0) || 0; + let durationMinutes = parseInt(this.durationMinutes, 10) || 0; let options = { timeLeft: duration.humanize(true), diff --git a/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js b/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js index 5891106f90..ac07d28a40 100644 --- a/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js +++ b/app/assets/javascripts/discourse/app/components/uppy-backup-uploader.js @@ -1,12 +1,29 @@ import Component from "@ember/component"; +import { alias, not } from "@ember/object/computed"; import I18n from "I18n"; import UppyUploadMixin from "discourse/mixins/uppy-upload"; import discourseComputed from "discourse-common/utils/decorators"; export default Component.extend(UppyUploadMixin, { + id: "uppy-backup-uploader", tagName: "span", type: "backup", - useMultipartUploadsIfAvailable: true, + + uploadRootPath: "/admin/backups", + uploadUrl: "/admin/backups/upload", + + // TODO (martin) Add functionality to make this usable _without_ multipart + // uploads, direct to S3, which needs to call get-presigned-put on the + // BackupsController (which extends ExternalUploadHelpers) rather than + // the old create_upload_url route. The two are functionally equivalent; + // they both generate a presigned PUT url for the upload to S3, and do + // the whole thing in one request rather than multipart. + + // direct s3 backups + useMultipartUploadsIfAvailable: not("localBackupStorage"), + + // local backups + useChunkedUploads: alias("localBackupStorage"), @discourseComputed("uploading", "uploadProgress") uploadButtonText(uploading, progress) { @@ -19,7 +36,7 @@ export default Component.extend(UppyUploadMixin, { return { skipValidation: true }; }, - uploadDone() { - this.done(); + uploadDone(responseData) { + this.done(responseData.file_name); }, }); diff --git a/app/assets/javascripts/discourse/app/components/user-stream.js b/app/assets/javascripts/discourse/app/components/user-stream.js index 19473885b4..6e45c375c7 100644 --- a/app/assets/javascripts/discourse/app/components/user-stream.js +++ b/app/assets/javascripts/discourse/app/components/user-stream.js @@ -5,6 +5,7 @@ import Draft from "discourse/models/draft"; import I18n from "I18n"; import LoadMore from "discourse/mixins/load-more"; import Post from "discourse/models/post"; +import { NEW_TOPIC_KEY } from "discourse/models/composer"; import bootbox from "bootbox"; import { getOwner } from "discourse-common/lib/get-owner"; import { observes } from "discourse-common/utils/decorators"; @@ -36,8 +37,6 @@ export default Component.extend(LoadMore, { }, _inserted: on("didInsertElement", function () { - this.bindScrolling({ name: "user-stream-view" }); - $(window).on("resize.discourse-on-scroll", () => this.scrolled()); $(this.element).on( @@ -53,7 +52,6 @@ export default Component.extend(LoadMore, { // This view is being removed. Shut down operations _destroyed: on("willDestroyElement", function () { - this.unbindScrolling("user-stream-view"); $(window).unbind("resize.discourse-on-scroll"); $(this.element).off("click.details-disabled", "details.disabled"); @@ -121,6 +119,9 @@ export default Component.extend(LoadMore, { Draft.clear(draft.draft_key, draft.sequence) .then(() => { stream.remove(draft); + if (draft.draft_key === NEW_TOPIC_KEY) { + this.currentUser.set("has_topic_draft", false); + } }) .catch((error) => { popupAjaxError(error); diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 47a8fc8abd..9d85a476ba 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -296,15 +296,6 @@ export default Controller.extend({ return option; }, - @discourseComputed() - composerComponent() { - const defaultComposer = "composer-editor"; - if (this.siteSettings.enable_experimental_composer_uploader) { - return "composer-editor-uppy"; - } - return defaultComposer; - }, - @discourseComputed("model.requiredCategoryMissing", "model.replyLength") disableTextarea(requiredCategoryMissing, replyLength) { return requiredCategoryMissing && replyLength === 0; @@ -719,6 +710,17 @@ export default Controller.extend({ }); }, + hereMention(count) { + this.appEvents.trigger("composer-messages:create", { + extraClass: "custom-body", + templateName: "custom-body", + body: I18n.t("composer.here_mention", { + here: this.siteSettings.here_mention, + count, + }), + }); + }, + applyUnorderedList() { this.toolbarEvent.applyList("* ", "list_item"); }, diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js index 02f78f0248..370f0c210f 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-account.js +++ b/app/assets/javascripts/discourse/app/controllers/create-account.js @@ -211,6 +211,10 @@ export default Controller.extend( return User.checkEmail(this.accountEmail) .then((result) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + if (result.failed) { this.setProperties({ serverAccountEmail: this.accountEmail, @@ -295,6 +299,10 @@ export default Controller.extend( this._hpPromise = ajax("/session/hp.json") .then((json) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + this._challengeDate = new Date(); // remove 30 seconds for jitter, make sure this works for at least // 30 seconds so we don't have hard loops @@ -352,6 +360,10 @@ export default Controller.extend( this.set("formSubmitted", true); return User.createAccount(attrs).then( (result) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + this.set("isDeveloper", false); if (result.success) { // invalidate honeypot diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 1a77404237..2a4cb7e841 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -9,6 +9,7 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; import Group from "discourse/models/group"; import Invite from "discourse/models/invite"; import I18n from "I18n"; +import { FORMAT } from "select-kit/components/future-date-input-selector"; export default Controller.extend( ModalFunctionality, @@ -16,13 +17,16 @@ export default Controller.extend( { allGroups: null, + flashText: null, + flashClass: null, + flashLink: false, + invite: null, invites: null, - showAdvanced: false, + editing: false, inviteToTopic: false, limitToEmail: false, - autogenerated: false, isLink: empty("buffered.email"), isEmail: notEmpty("buffered.email"), @@ -33,37 +37,33 @@ export default Controller.extend( }); this.setProperties({ + flashText: null, + flashClass: null, + flashLink: false, invite: null, invites: null, - showAdvanced: false, + editing: false, inviteToTopic: false, limitToEmail: false, - autogenerated: false, }); this.setInvite(Invite.create()); + this.buffered.setProperties({ + max_redemptions_allowed: 1, + expires_at: moment() + .add(this.siteSettings.invite_expiry_days, "days") + .format(FORMAT), + }); }, onClose() { - if (this.autogenerated) { - this.invite - .destroy() - .then(() => this.invites && this.invites.removeObject(this.invite)); - } + this.appEvents.trigger("modal-body:clearFlash"); }, setInvite(invite) { this.set("invite", invite); }, - setAutogenerated(value) { - if (this.invites && (this.autogenerated || !this.invite.id) && !value) { - this.invites.unshiftObject(this.invite); - } - - this.set("autogenerated", value); - }, - save(opts) { const data = { ...this.buffered.buffer }; @@ -101,29 +101,37 @@ export default Controller.extend( .save(data) .then((result) => { this.rollbackBuffer(); - this.setAutogenerated(opts.autogenerated); + + if ( + this.invites && + !this.invites.any((i) => i.id === this.invite.id) + ) { + this.invites.unshiftObject(this.invite); + } + if (result.warnings) { - this.appEvents.trigger("modal-body:flash", { - text: result.warnings.join(","), - messageClass: "warning", + this.setProperties({ + flashText: result.warnings.join(","), + flashClass: "warning", + flashLink: !this.editing, }); - } else if (!this.autogenerated) { + } else { if (this.isEmail && opts.sendEmail) { this.send("closeModal"); } else { - this.appEvents.trigger("modal-body:flash", { - text: opts.copy - ? I18n.t("user.invited.invite.invite_copied") - : I18n.t("user.invited.invite.invite_saved"), - messageClass: "success", + this.setProperties({ + flashText: I18n.t("user.invited.invite.invite_saved"), + flashClass: "success", + flashLink: !this.editing, }); } } }) .catch((e) => - this.appEvents.trigger("modal-body:flash", { - text: extractError(e), - messageClass: "error", + this.setProperties({ + flashText: extractError(e), + flashClass: "error", + flashLink: false, }) ); }, @@ -155,11 +163,6 @@ export default Controller.extend( return staff || groups.any((g) => g.owner); }, - @discourseComputed("currentUser.staff", "isEmail", "canInviteToGroup") - hasAdvanced(staff, isEmail, canInviteToGroup) { - return staff || isEmail || canInviteToGroup; - }, - @action copied() { this.save({ sendEmail: false, copy: true }); @@ -178,10 +181,5 @@ export default Controller.extend( this.set("buffered.email", result[0].email[0]); }); }, - - @action - toggleAdvanced() { - this.toggleProperty("showAdvanced"); - }, } ); diff --git a/app/assets/javascripts/discourse/app/controllers/discovery.js b/app/assets/javascripts/discourse/app/controllers/discovery.js index 354465da4e..c06ad44e2b 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery.js @@ -2,7 +2,6 @@ import Controller, { inject as controller } from "@ember/controller"; import { alias, equal, not } from "@ember/object/computed"; import Category from "discourse/models/category"; import DiscourseURL from "discourse/lib/url"; -import { observes } from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; export default Controller.extend({ @@ -14,7 +13,6 @@ export default Controller.extend({ "router.currentRouteName", "discovery.categories" ), - loading: false, category: alias("navigationCategory.category"), @@ -22,8 +20,13 @@ export default Controller.extend({ loadedAllItems: not("discoveryTopics.model.canLoadMore"), - @observes("loadedAllItems") - _showFooter() { + loadingBegan() { + this.set("loading", true); + this.set("application.showFooter", false); + }, + + loadingComplete() { + this.set("loading", false); this.set("application.showFooter", this.loadedAllItems); }, diff --git a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js index b7aefc9587..f19fbdcf22 100644 --- a/app/assets/javascripts/discourse/app/controllers/discovery/topics.js +++ b/app/assets/javascripts/discourse/app/controllers/discovery/topics.js @@ -66,15 +66,14 @@ const controllerOpts = { this.send("resetParams", options.skipResettingParams); // Don't refresh if we're still loading - if (this.get("discovery.loading")) { + if (this.discovery.loading) { return; } // If we `send('loading')` here, due to returning true it bubbles up to the // router and ember throws an error due to missing `handlerInfos`. // Lesson learned: Don't call `loading` yourself. - this.set("discovery.loading", true); - this.set("model.canLoadMore", true); + this.discovery.loadingBegan(); this.topicTrackingState.resetTracking(); diff --git a/app/assets/javascripts/discourse/app/controllers/dismiss-notification-confirmation.js b/app/assets/javascripts/discourse/app/controllers/dismiss-notification-confirmation.js new file mode 100644 index 0000000000..ff13e85c86 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/dismiss-notification-confirmation.js @@ -0,0 +1,11 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Controller.extend(ModalFunctionality, { + actions: { + dismiss() { + this.send("closeModal"); + this.dismissNotifications(); + }, + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/flag.js b/app/assets/javascripts/discourse/app/controllers/flag.js index 042bea17de..49ea8b525d 100644 --- a/app/assets/javascripts/discourse/app/controllers/flag.js +++ b/app/assets/javascripts/discourse/app/controllers/flag.js @@ -275,6 +275,10 @@ export default Controller.extend(ModalFunctionality, { postAction .act(this.model, params) .then(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + if (!params.skipClose) { this.send("closeModal"); } @@ -286,7 +290,9 @@ export default Controller.extend(ModalFunctionality, { }); }) .catch((error) => { - this.send("closeModal"); + if (!this.isDestroying && !this.isDestroyed) { + this.send("closeModal"); + } popupAjaxError(error); }); }, diff --git a/app/assets/javascripts/discourse/app/controllers/full-page-search.js b/app/assets/javascripts/discourse/app/controllers/full-page-search.js index d27a5734f5..d75bc34c9b 100644 --- a/app/assets/javascripts/discourse/app/controllers/full-page-search.js +++ b/app/assets/javascripts/discourse/app/controllers/full-page-search.js @@ -5,6 +5,7 @@ import { isValidSearchTerm, searchContextDescription, translateResults, + updateRecentSearches, } from "discourse/lib/search"; import Category from "discourse/models/category"; import Composer from "discourse/models/composer"; @@ -345,6 +346,9 @@ export default Controller.extend({ }); break; default: + if (this.currentUser) { + updateRecentSearches(this.currentUser, searchTerm); + } ajax("/search", { data: args }) .then(async (results) => { const model = (await translateResults(results)) || {}; diff --git a/app/assets/javascripts/discourse/app/controllers/history.js b/app/assets/javascripts/discourse/app/controllers/history.js index 18e36c478d..4888ce0f30 100644 --- a/app/assets/javascripts/discourse/app/controllers/history.js +++ b/app/assets/javascripts/discourse/app/controllers/history.js @@ -11,21 +11,17 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; import Post from "discourse/models/post"; import bootbox from "bootbox"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; -import { computed } from "@ember/object"; import { iconHTML } from "discourse-common/lib/icon-library"; import { sanitizeAsync } from "discourse/lib/text"; -function customTagArray(fieldName) { - return computed(fieldName, function () { - let val = this.get(fieldName); - if (!val) { - return val; - } - if (!Array.isArray(val)) { - val = [val]; - } - return val; - }); +function customTagArray(val) { + if (!val) { + return []; + } + if (!Array.isArray(val)) { + val = [val]; + } + return val; } // This controller handles displaying of history @@ -43,8 +39,33 @@ export default Controller.extend(ModalFunctionality, { previousFeaturedLink: alias("model.featured_link_changes.previous"), currentFeaturedLink: alias("model.featured_link_changes.current"), - previousTagChanges: customTagArray("model.tags_changes.previous"), - currentTagChanges: customTagArray("model.tags_changes.current"), + @discourseComputed( + "model.tags_changes.previous", + "model.tags_changes.current" + ) + previousTagChanges(previous, current) { + const previousArray = customTagArray(previous); + const currentSet = new Set(customTagArray(current)); + + return previousArray.map((name) => ({ + name, + deleted: !currentSet.has(name), + })); + }, + + @discourseComputed( + "model.tags_changes.previous", + "model.tags_changes.current" + ) + currentTagChanges(previous, current) { + const previousSet = new Set(customTagArray(previous)); + const currentArray = customTagArray(current); + + return currentArray.map((name) => ({ + name, + inserted: !previousSet.has(name), + })); + }, @discourseComputed("post.version") modalTitleKey(version) { diff --git a/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js b/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js index 65eacc5cdf..0386451056 100644 --- a/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js +++ b/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js @@ -232,6 +232,10 @@ export default Controller.extend(ModalFunctionality, { insert_url: buildShortcut("search_menu.insert_url", { keys1: ["a"], }), + full_page_search: buildShortcut("search_menu.full_page_search", { + keys1: [translateModKey("Meta"), "Enter"], + keysDelimiter: PLUS, + }), }, }); }, diff --git a/app/assets/javascripts/discourse/app/controllers/login.js b/app/assets/javascripts/discourse/app/controllers/login.js index b36eddc62c..ef43315ea9 100644 --- a/app/assets/javascripts/discourse/app/controllers/login.js +++ b/app/assets/javascripts/discourse/app/controllers/login.js @@ -160,6 +160,7 @@ export default Controller.extend(ModalFunctionality, { // Successful login if (result && result.error) { this.set("loggingIn", false); + this.clearFlash(); if ( (result.security_key_enabled || result.totp_enabled) && diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js index 8a769cee58..d91dde1b52 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js @@ -1,11 +1,6 @@ import Controller, { inject as controller } from "@ember/controller"; import Session from "discourse/models/session"; -import { - iOSWithVisualViewport, - isiPad, - safariHacksDisabled, - setDefaultHomepage, -} from "discourse/lib/utilities"; +import { setDefaultHomepage } from "discourse/lib/utilities"; import { listColorSchemes, loadColorSchemeStylesheet, @@ -72,18 +67,6 @@ export default Controller.extend({ return attrs; }, - @discourseComputed() - isiPad() { - // TODO: remove this preference checkbox when iOS adoption > 90% - // (currently only applies to iOS 12 and below) - return isiPad() && !iOSWithVisualViewport(); - }, - - @discourseComputed() - disableSafariHacks() { - return safariHacksDisabled(); - }, - @discourseComputed() availableLocales() { return JSON.parse(this.siteSettings.available_locales); @@ -342,16 +325,6 @@ export default Controller.extend({ this.homeChanged(); - if (this.isiPad) { - if (safariHacksDisabled() !== this.disableSafariHacks) { - this.session.requiresRefresh = true; - } - localStorage.setItem( - "safari-hacks-disabled", - this.disableSafariHacks.toString() - ); - } - if (this.themeId !== this.currentThemeId) { reload(); } diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js index 6e9403d255..26cd76c27d 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js @@ -34,21 +34,20 @@ export default Controller.extend({ @discourseComputed("model.user_fields.@each.value") userFields() { - let siteUserFields = this.site.get("user_fields"); - if (!isEmpty(siteUserFields)) { - const userFields = this.get("model.user_fields"); - - // Staff can edit fields that are not `editable` - if (!this.get("currentUser.staff")) { - siteUserFields = siteUserFields.filterBy("editable", true); - } - return siteUserFields.sortBy("position").map(function (field) { - const value = userFields - ? userFields[field.get("id").toString()] - : null; - return EmberObject.create({ value, field }); - }); + let siteUserFields = this.site.user_fields; + if (isEmpty(siteUserFields)) { + return; } + + // Staff can edit fields that are not `editable` + if (!this.currentUser.staff) { + siteUserFields = siteUserFields.filterBy("editable", true); + } + + return siteUserFields.sortBy("position").map((field) => { + const value = this.model.user_fields?.[field.id.toString()]; + return EmberObject.create({ field, value }); + }); }, @discourseComputed("model.default_calendar") diff --git a/app/assets/javascripts/discourse/app/controllers/rename-tag.js b/app/assets/javascripts/discourse/app/controllers/rename-tag.js deleted file mode 100644 index 1c5f1d618f..0000000000 --- a/app/assets/javascripts/discourse/app/controllers/rename-tag.js +++ /dev/null @@ -1,33 +0,0 @@ -import { action } from "@ember/object"; -import BufferedContent from "discourse/mixins/buffered-content"; -import Controller from "@ember/controller"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import discourseComputed from "discourse-common/utils/decorators"; -import { extractError } from "discourse/lib/ajax-error"; - -export default Controller.extend(ModalFunctionality, BufferedContent, { - newTag: null, - - @discourseComputed("newTag", "model.id") - renameDisabled(newTag, currentTag) { - const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); - newTag = newTag ? newTag.replace(filterRegexp, "").trim() : ""; - return newTag.length === 0 || newTag === currentTag; - }, - - @action - performRename() { - this.model - .update({ id: this.newTag }) - .then((result) => { - this.send("closeModal"); - - if (result.responseJson.tag) { - this.transitionToRoute("tag.show", result.responseJson.tag.id); - } else { - this.flash(extractError(result.responseJson.errors[0]), "error"); - } - }) - .catch((error) => this.flash(extractError(error), "error")); - }, -}); diff --git a/app/assets/javascripts/discourse/app/controllers/share-topic.js b/app/assets/javascripts/discourse/app/controllers/share-topic.js index 7f8d3e2a85..3c54976d9b 100644 --- a/app/assets/javascripts/discourse/app/controllers/share-topic.js +++ b/app/assets/javascripts/discourse/app/controllers/share-topic.js @@ -9,13 +9,21 @@ import showModal from "discourse/lib/show-modal"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import I18n from "I18n"; +import Category from "discourse/models/category"; export default Controller.extend( ModalFunctionality, bufferedProperty("invite"), { + topic: null, + restrictedGroups: null, + onShow() { this.set("showNotifyUsers", false); + + if (this.model && this.model.read_restricted) { + this.restrictedGroupWarning(); + } }, @discourseComputed("topic.shareUrl") @@ -97,15 +105,30 @@ export default Controller.extend( inviteUsers() { this.set("showNotifyUsers", false); const controller = showModal("create-invite"); - controller.setProperties({ - showAdvanced: true, - inviteToTopic: true, - }); + controller.set("inviteToTopic", true); controller.buffered.setProperties({ topicId: this.topic.id, topicTitle: this.topic.title, }); - controller.save({ autogenerated: true }); + }, + + restrictedGroupWarning() { + this.appEvents.on("modal:body-shown", () => { + let restrictedGroups; + Category.reloadBySlugPath(this.model.slug).then((result) => { + restrictedGroups = result.category.group_permissions.map( + (g) => g.group_name + ); + + if (restrictedGroups) { + const message = I18n.t("topic.share.restricted_groups", { + count: restrictedGroups.length, + groupNames: restrictedGroups.join(", "), + }); + this.flash(message, "warning"); + } + }); + }); }, } ); diff --git a/app/assets/javascripts/discourse/app/controllers/user-activity.js b/app/assets/javascripts/discourse/app/controllers/user-activity.js index cb7277ea5c..3bfda75bda 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-activity.js +++ b/app/assets/javascripts/discourse/app/controllers/user-activity.js @@ -35,6 +35,13 @@ export default Controller.extend({ : I18n.t("drafts.label"); }, + @discourseComputed("model.pending_posts_count") + pendingLabel(count) { + return count > 0 + ? I18n.t("pending_posts.label_with_count", { count }) + : I18n.t("pending_posts.label"); + }, + actions: { exportUserArchive() { bootbox.confirm( diff --git a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js index f7f98519a9..50c36460c1 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -66,7 +66,6 @@ export default Controller.extend({ createInvite() { const controller = showModal("create-invite"); controller.set("invites", this.model.invites); - controller.save({ autogenerated: true }); }, @action @@ -77,7 +76,7 @@ export default Controller.extend({ @action editInvite(invite) { const controller = showModal("create-invite"); - controller.set("showAdvanced", true); + controller.set("editing", true); controller.setInvite(invite); }, diff --git a/app/assets/javascripts/discourse/app/controllers/user-topics-list.js b/app/assets/javascripts/discourse/app/controllers/user-topics-list.js index f42350f8c3..021502f615 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-topics-list.js +++ b/app/assets/javascripts/discourse/app/controllers/user-topics-list.js @@ -86,4 +86,9 @@ export default Controller.extend(BulkTopicSelection, { this.pmTopicTrackingState.resetIncomingTracking(); return false; }, + + @action + refresh() { + this.send("triggerRefresh"); + }, }); diff --git a/app/assets/javascripts/discourse/app/initializers/ember-events.js b/app/assets/javascripts/discourse/app/initializers/ember-events.js index 4696e81293..6a2e14d344 100644 --- a/app/assets/javascripts/discourse/app/initializers/ember-events.js +++ b/app/assets/javascripts/discourse/app/initializers/ember-events.js @@ -5,14 +5,13 @@ export default { initialize() { // By default Ember listens to too many events. This tells it the only events - // we're interested in. (it removes mousemove and touchmove) + // we're interested in. (it removes mousemove, touchstart and touchmove) if (initializedOnce) { return; } Ember.EventDispatcher.reopen({ events: { - touchstart: "touchStart", touchend: "touchEnd", touchcancel: "touchCancel", keydown: "keyDown", diff --git a/app/assets/javascripts/discourse/app/initializers/message-bus.js b/app/assets/javascripts/discourse/app/initializers/message-bus.js index b7964f6a9f..fff5732c97 100644 --- a/app/assets/javascripts/discourse/app/initializers/message-bus.js +++ b/app/assets/javascripts/discourse/app/initializers/message-bus.js @@ -46,7 +46,7 @@ export default { messageBus.alwaysLongPoll = !isProduction(); messageBus.shouldLongPollCallback = () => - userPresent(LONG_POLL_AFTER_UNSEEN_TIME); + userPresent({ userUnseenTime: LONG_POLL_AFTER_UNSEEN_TIME }); // we do not want to start anything till document is complete messageBus.stop(); @@ -56,7 +56,11 @@ export default { // When 20 minutes pass we stop long polling due to "shouldLongPollCallback". onPresenceChange({ unseenTime: LONG_POLL_AFTER_UNSEEN_TIME, - callback: () => document.dispatchEvent(new Event("visibilitychange")), + callback: (present) => { + if (present && messageBus.onVisibilityChange) { + messageBus.onVisibilityChange(); + } + }, }); if (siteSettings.login_required && !user) { diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index 5b04730dd0..ee0290bf5e 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -32,7 +32,7 @@ export default { ); api.decorateCookedElement(lightbox, { id: "discourse-lightbox" }); if (siteSettings.support_mixed_text_direction) { - api.decorateCooked(setTextDirections, { + api.decorateCookedElement(setTextDirections, { id: "discourse-text-direction", }); } diff --git a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js index 7b6bfbff65..3f1bfed382 100644 --- a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js +++ b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js @@ -1,7 +1,4 @@ -import { - addComposerUploadPreProcessor, - addComposerUploadProcessor, -} from "discourse/components/composer-editor"; +import { addComposerUploadPreProcessor } from "discourse/components/composer-editor"; import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; export default { @@ -10,30 +7,18 @@ export default { initialize(container) { let siteSettings = container.lookup("site-settings:main"); if (siteSettings.composer_media_optimization_image_enabled) { - if (!siteSettings.enable_experimental_composer_uploader) { - addComposerUploadProcessor( - { action: "optimizeJPEG" }, - { - optimizeJPEG: (data, opts) => + addComposerUploadPreProcessor( + UppyMediaOptimization, + ({ isMobileDevice }) => { + return { + optimizeFn: (data, opts) => container .lookup("service:media-optimization-worker") .optimizeImage(data, opts), - } - ); - } else { - addComposerUploadPreProcessor( - UppyMediaOptimization, - ({ isMobileDevice }) => { - return { - optimizeFn: (data, opts) => - container - .lookup("service:media-optimization-worker") - .optimizeImage(data, opts), - runParallel: !isMobileDevice, - }; - } - ); - } + runParallel: !isMobileDevice, + }; + } + ); } }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/signup-cta.js b/app/assets/javascripts/discourse/app/initializers/signup-cta.js index 48df8e19d0..c44a1b00fa 100644 --- a/app/assets/javascripts/discourse/app/initializers/signup-cta.js +++ b/app/assets/javascripts/discourse/app/initializers/signup-cta.js @@ -14,6 +14,7 @@ export default { const siteSettings = container.lookup("site-settings:main"); const keyValueStore = container.lookup("key-value-store:main"); const user = container.lookup("current-user:main"); + const appEvents = container.lookup("service:app-events"); screenTrack.keyValueStore = keyValueStore; @@ -72,6 +73,7 @@ export default { // Requirements met. session.set("showSignupCta", true); + appEvents.trigger("cta:shown"); } screenTrack.registerAnonCallback(checkSignupCtaRequirements); diff --git a/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js b/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js new file mode 100644 index 0000000000..2fa8c87d7b --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js @@ -0,0 +1,14 @@ +import StickyAvatars from "discourse/lib/sticky-avatars"; + +export default { + name: "sticky-avatars", + after: "inject-objects", + + initialize(container) { + this._stickyAvatars = StickyAvatars.init(container); + }, + + teardown() { + this._stickyAvatars?.destroy(); + }, +}; diff --git a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js index a8aa789b9c..a5a5227771 100644 --- a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js +++ b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js @@ -23,7 +23,9 @@ export default { }, title: "topic.share.help", action() { - const controller = showModal("share-topic"); + const controller = showModal("share-topic", { + model: this.topic.category, + }); controller.setProperties({ allowInvites: this.canInviteTo && !this.inviteDisabled, topic: this.topic, diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 99b77405a9..38ab5601e0 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -46,8 +46,6 @@ const keys = { let inputTimeout; export default function (options) { - const autocompletePlugin = this; - if (this.length === 0) { return; } @@ -55,13 +53,11 @@ export default function (options) { if (options === "destroy" || options.updateData) { cancel(inputTimeout); - $(this) - .off("keyup.autocomplete") - .off("keydown.autocomplete") - .off("paste.autocomplete") - .off("click.autocomplete"); - - $(window).off("click.autocomplete"); + this[0].removeEventListener("keydown", handleKeyDown); + this[0].removeEventListener("keyup", handleKeyUp); + this[0].removeEventListener("paste", handlePaste); + this[0].removeEventListener("click", closeAutocomplete); + window.removeEventListener("click", closeAutocomplete); if (options === "destroy") { return; @@ -116,8 +112,12 @@ export default function (options) { const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea; let inputSelectedItems = []; + function handlePaste() { + later(() => me.trigger("keydown"), 50); + } + function closeAutocomplete() { - _autoCompletePopper && _autoCompletePopper.destroy(); + _autoCompletePopper?.destroy(); if (div) { div.hide().remove(); @@ -276,7 +276,7 @@ export default function (options) { this.val(""); completeStart = 0; wrap.click(function () { - autocompletePlugin.focus(); + this.focus(); return true; }); } @@ -309,9 +309,22 @@ export default function (options) { } ul.find("li").click(function () { selectedOption = ul.find("li").index(this); - completeTerm(autocompleteOptions[selectedOption]); - if (!options.single) { - me.focus(); + // hack for Gboard, see meta.discourse.org/t/-/187009/24 + if (autocompleteOptions == null) { + const opts = { ...options, _gboard_hack_force_lookup: true }; + const forcedAutocompleteOptions = dataSource(prevTerm, opts); + forcedAutocompleteOptions?.then((data) => { + updateAutoComplete(data); + completeTerm(autocompleteOptions[selectedOption]); + if (!options.single) { + me.focus(); + } + }); + } else { + completeTerm(autocompleteOptions[selectedOption]); + if (!options.single) { + me.focus(); + } } return false; }); @@ -398,7 +411,11 @@ export default function (options) { } function dataSource(term, opts) { - if (prevTerm === term) { + const force = opts._gboard_hack_force_lookup; + if (force) { + delete opts._gboard_hack_force_lookup; + } + if (prevTerm === term && !force) { return SKIP; } @@ -447,24 +464,17 @@ export default function (options) { closeAutocomplete(); }); - $(window).on("click.autocomplete", () => closeAutocomplete()); - $(this).on("click.autocomplete", () => closeAutocomplete()); - - $(this).on("paste.autocomplete", () => { - later(() => me.trigger("keydown"), 50); - }); - function checkTriggerRule(opts) { return options.triggerRule ? options.triggerRule(me[0], opts) : true; } - $(this).on("keyup.autocomplete", function (e) { + function handleKeyUp(e) { if (options.debounced) { discourseDebounce(this, performAutocomplete, e, INPUT_DELAY); } else { performAutocomplete(e); } - }); + } function performAutocomplete(e) { if ([keys.esc, keys.enter].indexOf(e.which) !== -1) { @@ -503,7 +513,7 @@ export default function (options) { } } - $(this).on("keydown.autocomplete", function (e) { + function handleKeyDown(e) { let c, i, initial, prev, prevIsGood, stopFound, term, total, userToComplete; let cp; @@ -565,6 +575,8 @@ export default function (options) { if (e.which === keys.esc) { if (div !== null) { closeAutocomplete(); + e.preventDefault(); + e.stopImmediatePropagation(); return false; } return true; @@ -602,7 +614,9 @@ export default function (options) { // We're cancelling it, really. return true; } + e.stopImmediatePropagation(); + e.preventDefault(); return false; case keys.upArrow: selectedOption = selectedOption - 1; @@ -610,6 +624,7 @@ export default function (options) { selectedOption = 0; } markSelected(); + e.preventDefault(); return false; case keys.downArrow: total = autocompleteOptions.length; @@ -621,6 +636,7 @@ export default function (options) { selectedOption = 0; } markSelected(); + e.preventDefault(); return false; case keys.backSpace: autocompleteOptions = null; @@ -652,7 +668,13 @@ export default function (options) { return true; } } - }); + } + + window.addEventListener("click", closeAutocomplete); + this[0].addEventListener("click", closeAutocomplete); + this[0].addEventListener("paste", handlePaste); + this[0].addEventListener("keyup", handleKeyUp); + this[0].addEventListener("keydown", handleKeyDown); return this; } diff --git a/app/assets/javascripts/discourse/app/lib/eyeline.js b/app/assets/javascripts/discourse/app/lib/eyeline.js index 76757e2e20..10d40b1187 100644 --- a/app/assets/javascripts/discourse/app/lib/eyeline.js +++ b/app/assets/javascripts/discourse/app/lib/eyeline.js @@ -19,10 +19,6 @@ configureEyeline(); // Track visible elements on the screen. export default EmberObject.extend(Evented, { - init() { - this._super(...arguments); - }, - update() { if (_skipUpdate) { return; diff --git a/app/assets/javascripts/discourse/app/lib/formatter.js b/app/assets/javascripts/discourse/app/lib/formatter.js index 90ddf88e1d..3a57516b18 100644 --- a/app/assets/javascripts/discourse/app/lib/formatter.js +++ b/app/assets/javascripts/discourse/app/lib/formatter.js @@ -55,6 +55,7 @@ export function updateRelativeAge(elems) { elems = elems.toArray(); deprecated("updateRelativeAge now expects a DOM NodeList", { since: "2.8.0.beta7", + dropFrom: "2.9.0.beta1", }); } diff --git a/app/assets/javascripts/discourse/app/lib/key-value-store.js b/app/assets/javascripts/discourse/app/lib/key-value-store.js index 94c67018b9..1b67bf52a3 100644 --- a/app/assets/javascripts/discourse/app/lib/key-value-store.js +++ b/app/assets/javascripts/discourse/app/lib/key-value-store.js @@ -14,11 +14,13 @@ try { safeLocalStorage = null; } -const KeyValueStore = function (ctx) { - this.context = ctx; -}; +export default class KeyValueStore { + context = null; + + constructor(ctx) { + this.context = ctx; + } -KeyValueStore.prototype = { abandonLocal() { if (!safeLocalStorage) { return; @@ -32,57 +34,64 @@ KeyValueStore.prototype = { } i--; } + return true; - }, + } remove(key) { if (!safeLocalStorage) { return; } + return safeLocalStorage.removeItem(this.context + key); - }, + } set(opts) { if (!safeLocalStorage) { return false; } + safeLocalStorage[this.context + opts.key] = opts.value; - }, + } setObject(opts) { this.set({ key: opts.key, value: JSON.stringify(opts.value) }); - }, + } get(key) { if (!safeLocalStorage) { return null; } return safeLocalStorage[this.context + key]; - }, + } getInt(key, def) { if (!def) { def = 0; } + if (!safeLocalStorage) { return def; } + const result = parseInt(this.get(key), 10); if (!isFinite(result)) { return def; } + return result; - }, + } getObject(key) { if (!safeLocalStorage) { return null; } + try { return JSON.parse(safeLocalStorage[this.context + key]); } catch (e) {} - }, -}; + } +} // API compatibility with `localStorage` KeyValueStore.prototype.getItem = KeyValueStore.prototype.get; @@ -90,5 +99,3 @@ KeyValueStore.prototype.removeItem = KeyValueStore.prototype.remove; KeyValueStore.prototype.setItem = function (key, value) { this.set({ key, value }); }; - -export default KeyValueStore; diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index b97787fed5..f3bd4367c3 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -104,7 +104,7 @@ export default { this.container = container; this._stopCallback(); - this.searchService = this.container.lookup("search-service:main"); + this.searchService = this.container.lookup("service:search"); this.appEvents = this.container.lookup("service:app-events"); this.currentUser = this.container.lookup("current-user:main"); this.siteSettings = this.container.lookup("site-settings:main"); @@ -744,9 +744,7 @@ export default { }, categoriesTopicsList() { - const setting = this.container.lookup("site-settings:main") - .desktop_category_page_style; - switch (setting) { + switch (this.siteSettings.desktop_category_page_style) { case "categories_with_featured_topics": return $(".latest .featured-topic"); case "categories_and_latest_topics": diff --git a/app/assets/javascripts/discourse/app/lib/link-hashtags.js b/app/assets/javascripts/discourse/app/lib/link-hashtags.js index 7d1fa26320..da3391d5d4 100644 --- a/app/assets/javascripts/discourse/app/lib/link-hashtags.js +++ b/app/assets/javascripts/discourse/app/lib/link-hashtags.js @@ -14,6 +14,7 @@ export function linkSeenHashtags(elem) { deprecated("linkSeenHashtags now expects a DOM node as first parameter", { since: "2.8.0.beta7", + dropFrom: "2.9.0.beta1", }); } diff --git a/app/assets/javascripts/discourse/app/lib/link-mentions.js b/app/assets/javascripts/discourse/app/lib/link-mentions.js index 8abfc47e5b..229b2c71f7 100644 --- a/app/assets/javascripts/discourse/app/lib/link-mentions.js +++ b/app/assets/javascripts/discourse/app/lib/link-mentions.js @@ -73,6 +73,7 @@ export function linkSeenMentions(elem, siteSettings) { deprecated("linkSeenMentions now expects a DOM node as first parameter", { since: "2.8.0.beta7", + dropFrom: "2.9.0.beta1", }); } diff --git a/app/assets/javascripts/discourse/app/lib/lock-on.js b/app/assets/javascripts/discourse/app/lib/lock-on.js index b8a86ae2f6..32a92b342b 100644 --- a/app/assets/javascripts/discourse/app/lib/lock-on.js +++ b/app/assets/javascripts/discourse/app/lib/lock-on.js @@ -87,7 +87,7 @@ export default class LockOn { const body = document.querySelector("body"); SCROLL_EVENTS.forEach((event) => { - body.addEventListener(event, this._scrollListener); + body.addEventListener(event, this._scrollListener, { passive: true }); }); } diff --git a/app/assets/javascripts/discourse/app/lib/page-visible.js b/app/assets/javascripts/discourse/app/lib/page-visible.js deleted file mode 100644 index 0771a2f3c0..0000000000 --- a/app/assets/javascripts/discourse/app/lib/page-visible.js +++ /dev/null @@ -1,15 +0,0 @@ -// for android we test webkit -let hiddenProperty = - document.hidden !== undefined - ? "hidden" - : document.webkitHidden !== undefined - ? "webkitHidden" - : undefined; - -export default function () { - if (hiddenProperty !== undefined) { - return !document[hiddenProperty]; - } else { - return document && document.hasFocus; - } -} diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 6b16d724d7..12f1b900c2 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -2,7 +2,6 @@ import ComposerEditor, { addComposerUploadHandler, addComposerUploadMarkdownResolver, addComposerUploadPreProcessor, - addComposerUploadProcessor, } from "discourse/components/composer-editor"; import { addButton, @@ -93,15 +92,18 @@ import { import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser"; import { downloadCalendar } from "discourse/lib/download-calendar"; -// If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = "0.13.1"; +// If you add any methods to the API ensure you bump up the version number +// based on Semantic Versioning 2.0.0. Please up the changelog at +// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version +// using the format described at https://keepachangelog.com/en/1.0.0/. +const PLUGIN_API_VERSION = "1.0.0"; // This helper prevents us from applying the same `modifyClass` over and over in test mode. function canModify(klass, type, resolverName, changes) { if (!changes.pluginId) { // eslint-disable-next-line no-console console.warn( - "To prevent errors, add a `pluginId` key to your changes when calling `modifyClass`" + "To prevent errors in tests, add a `pluginId` key to your `modifyClass` call. This will ensure the modification is only applied once." ); return true; } @@ -233,7 +235,7 @@ class PluginApi { * * // for the place in code that render a string * string() { - * return ""; + * return ""; * }, * * // for the places in code that render virtual dom elements @@ -243,7 +245,7 @@ class PluginApi { * namespace: "http://www.w3.org/2000/svg" * },[ * h("use", { - * "xlink:href": attributeHook("http://www.w3.org/1999/xlink", `#far-smile`), + * "href": attributeHook("http://www.w3.org/1999/xlink", `#far-smile`), * namespace: "http://www.w3.org/2000/svg" * })] * ); @@ -490,9 +492,17 @@ class PluginApi { * ``` * api.removePostMenuButton('like'); * ``` + * + * ``` + * api.removePostMenuButton('like', (attrs, state, siteSettings, settings, currentUser) => { + * if (attrs.post_number === 1) { + * return true; + * } + * }); + * ``` **/ - removePostMenuButton(name) { - removeButton(name); + removePostMenuButton(name, callback) { + removeButton(name, callback); } /** @@ -1012,44 +1022,22 @@ class PluginApi { } /** - * Registers a function to handle uploads for specified file types + * Registers a function to handle uploads for specified file types. * The normal uploading functionality will be bypassed if function returns * a falsy value. - * This only for uploads of individual files * * Example: * - * api.addComposerUploadHandler(["mp4", "mov"], (file, editor) => { - * console.log("Handling upload for", file.name); + * api.addComposerUploadHandler(["mp4", "mov"], (files, editor) => { + * files.forEach((file) => { + * console.log("Handling upload for", file.name); + * }); * }) */ addComposerUploadHandler(extensions, method) { addComposerUploadHandler(extensions, method); } - /** - * Registers a pre-processor for file uploads - * See https://github.com/blueimp/jQuery-File-Upload/wiki/Options#file-processing-options - * - * Useful for transforming to-be uploaded files client-side - * - * Example: - * - * api.addComposerUploadProcessor({action: 'myFileTransformation'}, { - * myFileTransformation(data, options) { - * let p = new Promise((resolve, reject) => { - * let file = data.files[data.index]; - * console.log(`Transforming ${file.name}`); - * // do work... - * resolve(data); - * }); - * return p; - * }); - */ - addComposerUploadProcessor(queueItem, actionItem) { - addComposerUploadProcessor(queueItem, actionItem); - } - /** * Registers a pre-processor for file uploads in the form * of an Uppy preprocessor plugin. @@ -1620,7 +1608,7 @@ function decorate(klass, evt, cb, id) { if (!id) { // eslint-disable-next-line no-console console.warn( - "`decorateCooked` should be supplied with an `id` option to avoid memory leaks." + "`decorateCooked` should be supplied with an `id` option to avoid memory leaks in test mode. The id will be used to ensure the decorator is only applied once." ); } else { if (!_decorated.has(klass)) { diff --git a/app/assets/javascripts/discourse/app/lib/render-tag.js b/app/assets/javascripts/discourse/app/lib/render-tag.js index d545ad10bc..418b37f1b4 100644 --- a/app/assets/javascripts/discourse/app/lib/render-tag.js +++ b/app/assets/javascripts/discourse/app/lib/render-tag.js @@ -44,6 +44,7 @@ export function defaultRenderTag(tag, params) { href + " data-tag-name=" + tag + + (params.description ? ' title="' + params.description + '" ' : "") + " class='" + classes.join(" ") + "'>" + diff --git a/app/assets/javascripts/discourse/app/lib/render-tags.js b/app/assets/javascripts/discourse/app/lib/render-tags.js index 14647d562c..d4aebe57ed 100644 --- a/app/assets/javascripts/discourse/app/lib/render-tags.js +++ b/app/assets/javascripts/discourse/app/lib/render-tags.js @@ -55,7 +55,13 @@ export default function (topic, params) { if (tags) { for (let i = 0; i < tags.length; i++) { buffer += - renderTag(tags[i], { isPrivateMessage, tagsForUser, tagName }) + " "; + renderTag(tags[i], { + description: + topic.tags_descriptions && topic.tags_descriptions[tags[i]], + isPrivateMessage, + tagsForUser, + tagName, + }) + " "; } } diff --git a/app/assets/javascripts/discourse/app/lib/safari-hacks.js b/app/assets/javascripts/discourse/app/lib/safari-hacks.js index 3f6c17fe15..5d9f8f0e5b 100644 --- a/app/assets/javascripts/discourse/app/lib/safari-hacks.js +++ b/app/assets/javascripts/discourse/app/lib/safari-hacks.js @@ -1,76 +1,8 @@ -import { - iOSWithVisualViewport, - safariHacksDisabled, -} from "discourse/lib/utilities"; import { INPUT_DELAY } from "discourse-common/config/environment"; import discourseDebounce from "discourse-common/lib/debounce"; import { helperContext } from "discourse-common/lib/helpers"; import { later } from "@ember/runloop"; -// TODO: remove calcHeight once iOS 13 adoption > 90% -// In iOS 13 and up we use visualViewport API to calculate height - -// we can't tell what the actual visible window height is -// because we cannot account for the height of the mobile keyboard -// and any other mobile autocomplete UI that may appear -// so let's be conservative here rather than trying to max out every -// available pixel of height for the editor -function calcHeight() { - // estimate 270 px for keyboard - let withoutKeyboard = window.innerHeight - 270; - const min = 270; - - // iPhone shrinks header and removes footer controls ( back / forward nav ) - // at 39px we are at the largest viewport - const portrait = window.innerHeight > window.innerWidth; - const smallViewport = - (portrait ? window.screen.height : window.screen.width) - - window.innerHeight > - 40; - - if (portrait) { - // iPhone SE, it is super small so just - // have a bit of crop - if (window.screen.height === 568) { - withoutKeyboard = 270; - } - - // iPhone 6/7/8 - if (window.screen.height === 667) { - withoutKeyboard = smallViewport ? 295 : 325; - } - - // iPhone 6/7/8 plus - if (window.screen.height === 736) { - withoutKeyboard = smallViewport ? 353 : 383; - } - - // iPhone X - if (window.screen.height === 812) { - withoutKeyboard = smallViewport ? 340 : 370; - } - - // iPhone Xs Max and iPhone Xʀ - if (window.screen.height === 896) { - withoutKeyboard = smallViewport ? 410 : 440; - } - - // iPad can use innerHeight cause it renders nothing in the footer - if (window.innerHeight > 920) { - withoutKeyboard -= 45; - } - } else { - // landscape - // iPad, we have a bigger keyboard - if (window.innerHeight > 665) { - withoutKeyboard -= 128; - } - } - - // iPad portrait also has a bigger keyboard - return Math.max(withoutKeyboard, min); -} - let workaroundActive = false; export function isWorkaroundActive() { @@ -80,7 +12,7 @@ export function isWorkaroundActive() { // per http://stackoverflow.com/questions/29001977/safari-in-ios8-is-scrolling-screen-when-fixed-elements-get-focus/29064810 function positioningWorkaround($fixedElement) { let caps = helperContext().capabilities; - if (!caps.isIOS || safariHacksDisabled()) { + if (!caps.isIOS) { return; } @@ -91,8 +23,6 @@ function positioningWorkaround($fixedElement) { }); const fixedElement = $fixedElement[0]; - const oldHeight = fixedElement.style.height; - let originalScrollTop = 0; let lastTouchedElement = null; @@ -106,11 +36,6 @@ function positioningWorkaround($fixedElement) { } workaroundActive = false; - - if (!iOSWithVisualViewport()) { - fixedElement.style.height = oldHeight; - later(() => $(fixedElement).removeClass("no-transition"), 500); - } } }; @@ -172,8 +97,8 @@ function positioningWorkaround($fixedElement) { let delay = caps.isIpadOS ? 350 : 150; - later(function () { - if (caps.isIpadOS && iOSWithVisualViewport()) { + later(() => { + if (caps.isIpadOS) { // disable hacks when using a hardware keyboard // by default, a hardware keyboard will show the keyboard accessory bar // whose height is currently 55px (using 75 for a bit of a buffer) @@ -191,12 +116,6 @@ function positioningWorkaround($fixedElement) { document.body.classList.add("ios-safari-composer-hacks"); window.scrollTo(0, 0); - if (!iOSWithVisualViewport()) { - const height = calcHeight(); - fixedElement.style.height = height + "px"; - $(fixedElement).addClass("no-transition"); - } - evt.preventDefault(); _this.focus(); workaroundActive = true; diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index 09cdeaea25..6df0545ede 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -17,6 +17,7 @@ import { userPath } from "discourse/lib/url"; import userSearch from "discourse/lib/user-search"; const translateResultsCallbacks = []; +const MAX_RECENT_SEARCHES = 5; // should match backend constant with the same name export function addSearchResultsCallback(callback) { translateResultsCallbacks.push(callback); @@ -230,3 +231,16 @@ export function applySearchAutocomplete($input, siteSettings) { ); } } + +export function updateRecentSearches(currentUser, term) { + let recentSearches = Object.assign(currentUser.recent_searches || []); + + if (recentSearches.includes(term)) { + recentSearches = recentSearches.without(term); + } else if (recentSearches.length === MAX_RECENT_SEARCHES) { + recentSearches.popObject(); + } + + recentSearches.unshiftObject(term); + currentUser.set("recent_searches", recentSearches); +} diff --git a/app/assets/javascripts/discourse/app/lib/sticky-avatars.js b/app/assets/javascripts/discourse/app/lib/sticky-avatars.js new file mode 100644 index 0000000000..4b39516e51 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/sticky-avatars.js @@ -0,0 +1,116 @@ +import { addWidgetCleanCallback } from "discourse/components/mount-widget"; +import Site from "discourse/models/site"; +import { bind } from "discourse-common/utils/decorators"; +import { schedule } from "@ember/runloop"; + +export default class StickyAvatars { + stickyClass = "sticky-avatar"; + topicPostSelector = "#topic .post-stream .topic-post"; + intersectionObserver = null; + direction = "⬇️"; + prevOffset = -1; + + static init(container) { + return new this(container).init(); + } + + constructor(container) { + this.container = container; + } + + init() { + if (Site.currentProp("mobileView") || !("IntersectionObserver" in window)) { + return; + } + + const appEvents = this.container.lookup("service:app-events"); + appEvents.on("topic:current-post-scrolled", this._handlePostNodes); + appEvents.on("topic:scrolled", this._handleScroll); + appEvents.on("page:topic-loaded", this._initIntersectionObserver); + + addWidgetCleanCallback("post-stream", this._clearIntersectionObserver); + + return this; + } + + destroy() { + this.container = null; + } + + @bind + _handleScroll(offset) { + if (offset <= 0) { + this.direction = "⬇️"; + document + .querySelectorAll(`${this.topicPostSelector}.${this.stickyClass}`) + .forEach((node) => node.classList.remove(this.stickyClass)); + } else if (offset > this.prevOffset) { + this.direction = "⬇️"; + } else { + this.direction = "⬆️"; + } + this.prevOffset = offset; + } + + @bind + _handlePostNodes() { + this._clearIntersectionObserver(); + this._initIntersectionObserver(); + + schedule("afterRender", () => { + document.querySelectorAll(this.topicPostSelector).forEach((postNode) => { + this.intersectionObserver.observe(postNode); + + const topicAvatarNode = postNode.querySelector(".topic-avatar"); + if (!topicAvatarNode || !postNode.querySelector("#post_1")) { + return; + } + + const topicMapNode = postNode.querySelector(".topic-map"); + if (!topicMapNode) { + return; + } + topicAvatarNode.style.marginBottom = `${topicMapNode.clientHeight}px`; + }); + }); + } + + @bind + _initIntersectionObserver() { + schedule("afterRender", () => { + const headerOffset = + parseInt( + getComputedStyle(document.body).getPropertyValue("--header-offset"), + 10 + ) || 0; + const headerHeight = Math.max(headerOffset, 0); + + this.intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting || entry.intersectionRatio === 1) { + entry.target.classList.remove(this.stickyClass); + return; + } + + const postContentHeight = entry.target.querySelector(".contents") + ?.clientHeight; + if ( + this.direction === "⬆️" || + postContentHeight > window.innerHeight - headerHeight + ) { + entry.target.classList.add(this.stickyClass); + } + }); + }, + { threshold: [0.0, 1.0], rootMargin: `-${headerHeight}px 0px 0px 0px` } + ); + }); + } + + @bind + _clearIntersectionObserver() { + this.intersectionObserver?.disconnect(); + this.intersectionObserver = null; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/text-direction.js b/app/assets/javascripts/discourse/app/lib/text-direction.js index b3da0964b6..9c01d1d3fa 100644 --- a/app/assets/javascripts/discourse/app/lib/text-direction.js +++ b/app/assets/javascripts/discourse/app/lib/text-direction.js @@ -13,19 +13,19 @@ export function isLTR(text) { return ltrDirCheck.test(text); } -export function setTextDirections($elem) { - $elem.find("*").each((i, e) => { - let $e = $(e), - textContent = $e.text(); - if (textContent) { - isRTL(textContent) ? $e.attr("dir", "rtl") : $e.attr("dir", "ltr"); +export function setTextDirections(elem) { + for (let e of elem.children) { + if (e.textContent) { + e.setAttribute("dir", isRTL(e.textContent) ? "rtl" : "ltr"); } - }); + } } export function siteDir() { if (!_siteDir) { - _siteDir = $("html").hasClass("rtl") ? "rtl" : "ltr"; + _siteDir = document.documentElement.classList.contains("rtl") + ? "rtl" + : "ltr"; } return _siteDir; } diff --git a/app/assets/javascripts/discourse/app/lib/text.js b/app/assets/javascripts/discourse/app/lib/text.js index ea40982c6c..6e7def0ecd 100644 --- a/app/assets/javascripts/discourse/app/lib/text.js +++ b/app/assets/javascripts/discourse/app/lib/text.js @@ -50,6 +50,13 @@ export function generateCookFunction(options) { }); } +export function generateLinkifyFunction(options) { + return loadMarkdownIt().then(() => { + const prettyText = createPrettyText(options); + return prettyText.opts.engine.linkify; + }); +} + export function sanitize(text, options) { return textSanitize(text, new AllowLister(options)); } diff --git a/app/assets/javascripts/discourse/app/lib/theme-selector.js b/app/assets/javascripts/discourse/app/lib/theme-selector.js index 4d7c9a9e50..7d6ca354cc 100644 --- a/app/assets/javascripts/discourse/app/lib/theme-selector.js +++ b/app/assets/javascripts/discourse/app/lib/theme-selector.js @@ -16,7 +16,7 @@ export function currentThemeKey() { export function currentThemeIds() { const themeIds = []; - const elem = $(keySelector)[0]; + const elem = document.querySelector(keySelector); if (elem) { elem.content.split(",").forEach((num) => { num = parseInt(num, 10); diff --git a/app/assets/javascripts/discourse/app/lib/uploads.js b/app/assets/javascripts/discourse/app/lib/uploads.js index dc1f9605a5..ba3a214b9f 100644 --- a/app/assets/javascripts/discourse/app/lib/uploads.js +++ b/app/assets/javascripts/discourse/app/lib/uploads.js @@ -202,7 +202,11 @@ export function authorizesOneOrMoreExtensions(staff, siteSettings) { return ( siteSettings.authorized_extensions.split("|").filter((ext) => ext).length > - 0 + 0 || + (siteSettings.authorized_extensions_for_staff + .split("|") + .filter((ext) => ext).length > 0 && + staff) ); } diff --git a/app/assets/javascripts/discourse/app/lib/uppy-chunked-upload.js b/app/assets/javascripts/discourse/app/lib/uppy-chunked-upload.js new file mode 100644 index 0000000000..73edc96f0e --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/uppy-chunked-upload.js @@ -0,0 +1,339 @@ +import { Promise } from "rsvp"; +import delay from "@uppy/utils/lib/delay"; +import { + AbortController, + createAbortError, +} from "@uppy/utils/lib/AbortController"; + +const MB = 1024 * 1024; + +const defaultOptions = { + limit: 5, + retryDelays: [0, 1000, 3000, 5000], + getChunkSize() { + return 5 * MB; + }, + onStart() {}, + onProgress() {}, + onChunkComplete() {}, + onSuccess() {}, + onError(err) { + throw err; + }, +}; + +/** + * Used mainly as a replacement for Resumable.js, using code cribbed from + * uppy's S3 Multipart class, which we mainly use the chunking algorithm + * and retry/abort functions of. The _buildFormData function is the one + * which shapes the data into the same parameters as Resumable.js used. + * + * See the UppyChunkedUploader class for the uppy uploader plugin which + * uses UppyChunkedUpload. + */ +export default class UppyChunkedUpload { + constructor(file, options) { + this.options = { + ...defaultOptions, + ...options, + }; + this.file = file; + + if (!this.options.getChunkSize) { + this.options.getChunkSize = defaultOptions.getChunkSize; + this.chunkSize = this.options.getChunkSize(this.file); + } + + this.abortController = new AbortController(); + this._initChunks(); + } + + _aborted() { + return this.abortController.signal.aborted; + } + + _initChunks() { + this.chunksInProgress = 0; + this.chunks = null; + this.chunkState = null; + + const chunks = []; + + if (this.file.size === 0) { + chunks.push(this.file.data); + } else { + for (let i = 0; i < this.file.data.size; i += this.chunkSize) { + const end = Math.min(this.file.data.size, i + this.chunkSize); + chunks.push(this.file.data.slice(i, end)); + } + } + + this.chunks = chunks; + this.chunkState = chunks.map(() => ({ + bytesUploaded: 0, + busy: false, + done: false, + })); + } + + _createUpload() { + if (this._aborted()) { + throw createAbortError(); + } + this.options.onStart(); + this._uploadChunks(); + } + + _uploadChunks() { + if (this.chunkState.every((state) => state.done)) { + this._completeUpload(); + return; + } + + // For a 100MB file, with the default min chunk size of 5MB and a limit of 10: + // + // Total 20 chunks + // --------- + // Need 1 is 10 + // Need 2 is 5 + // Need 3 is 5 + const need = this.options.limit - this.chunksInProgress; + const completeChunks = this.chunkState.filter((state) => state.done).length; + const remainingChunks = this.chunks.length - completeChunks; + let minNeeded = Math.ceil(this.options.limit / 2); + if (minNeeded > remainingChunks) { + minNeeded = remainingChunks; + } + if (need < minNeeded) { + return; + } + + const candidates = []; + for (let i = 0; i < this.chunkState.length; i++) { + const state = this.chunkState[i]; + if (!state.done && !state.busy) { + candidates.push(i); + if (candidates.length >= need) { + break; + } + } + } + + if (candidates.length === 0) { + return; + } + + candidates.forEach((index) => { + this._uploadChunkRetryable(index).then( + () => { + this._uploadChunks(); + }, + (err) => { + this._onError(err); + } + ); + }); + } + + _shouldRetry(err) { + if (err.source && typeof err.source.status === "number") { + const { status } = err.source; + // 0 probably indicates network failure + return ( + status === 0 || + status === 409 || + status === 423 || + (status >= 500 && status < 600) + ); + } + return false; + } + + _retryable({ before, attempt, after }) { + const { retryDelays } = this.options; + const { signal } = this.abortController; + + if (before) { + before(); + } + + const doAttempt = (retryAttempt) => + attempt().catch((err) => { + if (this._aborted()) { + throw createAbortError(); + } + + if (this._shouldRetry(err) && retryAttempt < retryDelays.length) { + return delay(retryDelays[retryAttempt], { signal }).then(() => + doAttempt(retryAttempt + 1) + ); + } + throw err; + }); + + return doAttempt(0).then( + (result) => { + if (after) { + after(); + } + return result; + }, + (err) => { + if (after) { + after(); + } + throw err; + } + ); + } + + _uploadChunkRetryable(index) { + return this._retryable({ + before: () => { + this.chunksInProgress += 1; + }, + attempt: () => this._uploadChunk(index), + after: () => { + this.chunksInProgress -= 1; + }, + }); + } + + _uploadChunk(index) { + this.chunkState[index].busy = true; + + if (this._aborted()) { + this.chunkState[index].busy = false; + throw createAbortError(); + } + + return this._uploadChunkBytes( + index, + this.options.url, + this.options.headers + ); + } + + _onChunkProgress(index, sent) { + this.chunkState[index].bytesUploaded = parseInt(sent, 10); + + const totalUploaded = this.chunkState.reduce( + (total, chunk) => total + chunk.bytesUploaded, + 0 + ); + this.options.onProgress(totalUploaded, this.file.data.size); + } + + _onChunkComplete(index) { + this.chunkState[index].done = true; + this.options.onChunkComplete(index); + } + + _uploadChunkBytes(index, url, headers) { + const body = this.chunks[index]; + const { signal } = this.abortController; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + function cleanup() { + signal.removeEventListener("abort", () => xhr.abort()); + } + signal.addEventListener("abort", xhr.abort()); + + xhr.open(this.options.method || "POST", url, true); + if (headers) { + Object.keys(headers).forEach((key) => { + xhr.setRequestHeader(key, headers[key]); + }); + } + xhr.responseType = "text"; + xhr.upload.addEventListener("progress", (ev) => { + if (!ev.lengthComputable) { + return; + } + + this._onChunkProgress(index, ev.loaded, ev.total); + }); + + xhr.addEventListener("abort", () => { + cleanup(); + this.chunkState[index].busy = false; + + reject(createAbortError()); + }); + + xhr.addEventListener("load", (ev) => { + cleanup(); + this.chunkState[index].busy = false; + + if (ev.target.status < 200 || ev.target.status >= 300) { + const error = new Error("Non 2xx"); + error.source = ev.target; + reject(error); + return; + } + + // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers. + this.chunks[index] = null; + + this._onChunkProgress(index, body.size, body.size); + + this._onChunkComplete(index); + resolve(); + }); + + xhr.addEventListener("error", (ev) => { + cleanup(); + this.chunkState[index].busy = false; + + const error = new Error("Unknown error"); + error.source = ev.target; + reject(error); + }); + + xhr.send(this._buildFormData(index + 1, body)); + }); + } + + async _completeUpload() { + this.options.onSuccess(); + } + + _buildFormData(currentChunkNumber, body) { + const uniqueIdentifier = + this.file.data.size + + "-" + + this.file.data.name.replace(/[^0-9a-zA-Z_-]/gim, ""); + const formData = new FormData(); + formData.append("file", body); + formData.append("resumableChunkNumber", currentChunkNumber); + formData.append("resumableCurrentChunkSize", body.size); + formData.append("resumableChunkSize", this.chunkSize); + formData.append("resumableTotalSize", this.file.data.size); + formData.append("resumableFilename", this.file.data.name); + formData.append("resumableIdentifier", uniqueIdentifier); + return formData; + } + + _abortUpload() { + this.abortController.abort(); + } + + _onError(err) { + if (err && err.name === "AbortError") { + return; + } + + this.options.onError(err); + } + + start() { + this._createUpload(); + } + + abort(opts = undefined) { + if (opts?.really) { + this._abortUpload(); + } + } +} diff --git a/app/assets/javascripts/discourse/app/lib/uppy-chunked-uploader-plugin.js b/app/assets/javascripts/discourse/app/lib/uppy-chunked-uploader-plugin.js new file mode 100644 index 0000000000..44ec635856 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/uppy-chunked-uploader-plugin.js @@ -0,0 +1,211 @@ +import { UploaderPlugin } from "discourse/lib/uppy-plugin-base"; +import { next } from "@ember/runloop"; +import getURL from "discourse-common/lib/get-url"; +import { Promise } from "rsvp"; +import UppyChunkedUpload from "discourse/lib/uppy-chunked-upload"; +import EventTracker from "@uppy/utils/lib/EventTracker"; + +// Limited use uppy uploader function to replace Resumable.js, which +// is only used by the local backup uploader at this point in time, +// and has been that way for many years. Uses the skeleton of uppy's +// AwsS3Multipart uploader plugin to provide a similar API, with unnecessary +// code removed. +// +// See also UppyChunkedUpload class for more detail. +export default class UppyChunkedUploader extends UploaderPlugin { + static pluginId = "uppy-chunked-uploader"; + + constructor(uppy, opts) { + super(uppy, opts); + const defaultOptions = { + limit: 0, + retryDelays: [0, 1000, 3000, 5000], + }; + + this.opts = { ...defaultOptions, ...opts }; + this.url = getURL(opts.url); + this.method = opts.method || "POST"; + + this.uploaders = Object.create(null); + this.uploaderEvents = Object.create(null); + } + + _resetUploaderReferences(fileID, opts = {}) { + if (this.uploaders[fileID]) { + this.uploaders[fileID].abort({ really: opts.abort || false }); + this.uploaders[fileID] = null; + } + if (this.uploaderEvents[fileID]) { + this.uploaderEvents[fileID].remove(); + this.uploaderEvents[fileID] = null; + } + } + + _uploadFile(file) { + return new Promise((resolve, reject) => { + const onStart = () => { + this.uppy.emit("upload-started", file); + }; + + const onProgress = (bytesUploaded, bytesTotal) => { + this.uppy.emit("upload-progress", file, { + uploader: this, + bytesUploaded, + bytesTotal, + }); + }; + + const onError = (err) => { + this.uppy.log(err); + this.uppy.emit("upload-error", file, err); + + this._resetUploaderReferences(file.id); + reject(err); + }; + + const onSuccess = () => { + this._resetUploaderReferences(file.id); + + const cFile = this.uppy.getFile(file.id); + const uploadResponse = {}; + this.uppy.emit("upload-success", cFile || file, uploadResponse); + + resolve(upload); + }; + + const onChunkComplete = (chunk) => { + const cFile = this.uppy.getFile(file.id); + if (!cFile) { + return; + } + + this.uppy.emit("chunk-uploaded", cFile, chunk); + }; + + const upload = new UppyChunkedUpload(file, { + getChunkSize: this.opts.getChunkSize + ? this.opts.getChunkSize.bind(this) + : null, + + onStart, + onProgress, + onChunkComplete, + onSuccess, + onError, + + limit: this.opts.limit || 5, + retryDelays: this.opts.retryDelays || [], + method: this.method, + url: this.url, + headers: this.opts.headers, + }); + + this.uploaders[file.id] = upload; + this.uploaderEvents[file.id] = new EventTracker(this.uppy); + + next(() => { + if (!file.isPaused) { + upload.start(); + } + }); + + this._onFileRemove(file.id, (removed) => { + this._resetUploaderReferences(file.id, { abort: true }); + resolve(`upload ${removed.id} was removed`); + }); + + this._onCancelAll(file.id, () => { + this._resetUploaderReferences(file.id, { abort: true }); + resolve(`upload ${file.id} was canceled`); + }); + + this._onFilePause(file.id, (isPaused) => { + if (isPaused) { + upload.pause(); + } else { + next(() => { + upload.start(); + }); + } + }); + + this._onPauseAll(file.id, () => { + upload.pause(); + }); + + this._onResumeAll(file.id, () => { + if (file.error) { + upload.abort(); + } + next(() => { + upload.start(); + }); + }); + + // Don't double-emit upload-started for restored files that were already started + if (!file.progress.uploadStarted || !file.isRestored) { + this.uppy.emit("upload-started", file); + } + }); + } + + _onFileRemove(fileID, cb) { + this.uploaderEvents[fileID].on("file-removed", (file) => { + if (fileID === file.id) { + cb(file.id); + } + }); + } + + _onFilePause(fileID, cb) { + this.uploaderEvents[fileID].on("upload-pause", (targetFileID, isPaused) => { + if (fileID === targetFileID) { + cb(isPaused); + } + }); + } + + _onPauseAll(fileID, cb) { + this.uploaderEvents[fileID].on("pause-all", () => { + if (!this.uppy.getFile(fileID)) { + return; + } + cb(); + }); + } + + _onCancelAll(fileID, cb) { + this.uploaderEvents[fileID].on("cancel-all", () => { + if (!this.uppy.getFile(fileID)) { + return; + } + cb(); + }); + } + + _onResumeAll(fileID, cb) { + this.uploaderEvents[fileID].on("resume-all", () => { + if (!this.uppy.getFile(fileID)) { + return; + } + cb(); + }); + } + + _upload(fileIDs) { + const promises = fileIDs.map((id) => { + const file = this.uppy.getFile(id); + return this._uploadFile(file); + }); + + return Promise.all(promises); + } + + install() { + this._install(this._upload.bind(this)); + } + + uninstall() { + this._uninstall(this._upload.bind(this)); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/uppy-plugin-base.js b/app/assets/javascripts/discourse/app/lib/uppy-plugin-base.js index f7a0f87146..51e5907ebd 100644 --- a/app/assets/javascripts/discourse/app/lib/uppy-plugin-base.js +++ b/app/assets/javascripts/discourse/app/lib/uppy-plugin-base.js @@ -30,32 +30,6 @@ export class UppyPluginBase extends BasePlugin { _setFileState(fileId, state) { this.uppy.setFileState(fileId, state); } -} - -export class UploadPreProcessorPlugin extends UppyPluginBase { - static pluginType = "preprocessor"; - - constructor(uppy, opts) { - super(uppy, opts); - this.type = this.constructor.pluginType; - } - - _install(fn) { - this.uppy.addPreProcessor(fn); - } - - _uninstall(fn) { - this.uppy.removePreProcessor(fn); - } - - _emitProgress(file) { - this.uppy.emit("preprocess-progress", file, null, this.id); - } - - _emitComplete(file, skipped = false) { - this.uppy.emit("preprocess-complete", file, skipped, this.id); - return Promise.resolve(); - } _emitAllComplete(fileIds, skipped = false) { fileIds.forEach((fileId) => { @@ -82,3 +56,55 @@ export class UploadPreProcessorPlugin extends UppyPluginBase { return this._emitAllComplete(file, true); } } + +export class UploadPreProcessorPlugin extends UppyPluginBase { + static pluginType = "preprocessor"; + + constructor(uppy, opts) { + super(uppy, opts); + this.type = this.constructor.pluginType; + } + + _install(fn) { + this.uppy.addPreProcessor(fn); + } + + _uninstall(fn) { + this.uppy.removePreProcessor(fn); + } + + _emitProgress(file) { + this.uppy.emit("preprocess-progress", file, null, this.id); + } + + _emitComplete(file, skipped = false) { + this.uppy.emit("preprocess-complete", file, skipped, this.id); + return Promise.resolve(); + } +} + +export class UploaderPlugin extends UppyPluginBase { + static pluginType = "uploader"; + + constructor(uppy, opts) { + super(uppy, opts); + this.type = this.constructor.pluginType; + } + + _install(fn) { + this.uppy.addUploader(fn); + } + + _uninstall(fn) { + this.uppy.removeUploader(fn); + } + + _emitProgress(file) { + this.uppy.emit("upload-progress", file, null, this.id); + } + + _emitComplete(file, skipped = false) { + this.uppy.emit("upload-complete", file, skipped, this.id); + return Promise.resolve(); + } +} diff --git a/app/assets/javascripts/discourse/app/lib/user-presence.js b/app/assets/javascripts/discourse/app/lib/user-presence.js index 92f17111e8..7fe1af6c6c 100644 --- a/app/assets/javascripts/discourse/app/lib/user-presence.js +++ b/app/assets/javascripts/discourse/app/lib/user-presence.js @@ -1,68 +1,138 @@ -// for android we test webkit -const hiddenProperty = - document.hidden !== undefined - ? "hidden" - : document.webkitHidden !== undefined - ? "webkitHidden" - : undefined; - -const MAX_UNSEEN_TIME = 60000; - -let seenUserTime = Date.now(); - -export default function (maxUnseenTime) { - maxUnseenTime = maxUnseenTime === undefined ? MAX_UNSEEN_TIME : maxUnseenTime; - const now = Date.now(); - - if (seenUserTime + maxUnseenTime < now) { - return false; - } - - if (hiddenProperty !== undefined) { - return !document[hiddenProperty]; - } else { - return document && document.hasFocus; - } -} +import { isTesting } from "discourse-common/config/environment"; const callbacks = []; -const MIN_DELTA = 60000; +const DEFAULT_USER_UNSEEN_MS = 60000; +const DEFAULT_BROWSER_HIDDEN_MS = 0; + +let browserHiddenAt = null; +let lastUserActivity = Date.now(); +let userSeenJustNow = false; + +let callbackWaitingForPresence = false; + +let testPresence = true; + +// Check whether the document is currently visible, and the user is actively using the site +// Will return false if the browser went into the background more than `browserHiddenTime` milliseconds ago +// Will also return false if there has been no user activty for more than `userUnseenTime` milliseconds +// Otherwise, will return true +export default function userPresent({ + browserHiddenTime = DEFAULT_BROWSER_HIDDEN_MS, + userUnseenTime = DEFAULT_USER_UNSEEN_MS, +} = {}) { + if (isTesting()) { + return testPresence; + } + + if (browserHiddenAt) { + const timeSinceBrowserHidden = Date.now() - browserHiddenAt; + if (timeSinceBrowserHidden >= browserHiddenTime) { + return false; + } + } + + const timeSinceUserActivity = Date.now() - lastUserActivity; + if (timeSinceUserActivity >= userUnseenTime) { + return false; + } + + return true; +} + +// Register a callback to be triggered when the value of `userPresent()` changes. +// userUnseenTime and browserHiddenTime work the same as for `userPresent()` +// 'not present' callbacks may lag by up to 10s, depending on the reason +// 'now present' callbacks should be almost instantaneous +export function onPresenceChange({ + userUnseenTime = DEFAULT_USER_UNSEEN_MS, + browserHiddenTime = DEFAULT_BROWSER_HIDDEN_MS, + callback, +} = {}) { + if (userUnseenTime < DEFAULT_USER_UNSEEN_MS) { + throw `userUnseenTime must be at least ${DEFAULT_USER_UNSEEN_MS}`; + } + callbacks.push({ + userUnseenTime, + browserHiddenTime, + lastState: true, + callback, + }); +} + +export function removeOnPresenceChange(callback) { + const i = callbacks.findIndex((c) => c.callback === callback); + callbacks.splice(i, 1); +} + +function processChanges() { + const browserHidden = document.hidden; + if (!!browserHiddenAt !== browserHidden) { + browserHiddenAt = browserHidden ? Date.now() : null; + } + + if (userSeenJustNow) { + lastUserActivity = Date.now(); + userSeenJustNow = false; + } + + callbackWaitingForPresence = false; + for (const callback of callbacks) { + const currentState = userPresent({ + userUnseenTime: callback.userUnseenTime, + browserHiddenTime: callback.browserHiddenTime, + }); + + if (callback.lastState !== currentState) { + try { + callback.callback(currentState); + } finally { + callback.lastState = currentState; + } + } + + if (!currentState) { + callbackWaitingForPresence = true; + } + } +} export function seenUser() { - let lastSeenTime = seenUserTime; - seenUserTime = Date.now(); - let delta = seenUserTime - lastSeenTime; - - if (lastSeenTime && delta > MIN_DELTA) { - callbacks.forEach((info) => { - if (delta > info.unseenTime) { - info.callback(); - } - }); + userSeenJustNow = true; + if (callbackWaitingForPresence) { + processChanges(); } } -// register a callback for cases where presence changed -export function onPresenceChange({ unseenTime, callback }) { - if (unseenTime < MIN_DELTA) { - throw "unseenTime is too short"; +export function visibilityChanged() { + if (document.hidden) { + processChanges(); + } else { + seenUser(); } - callbacks.push({ unseenTime, callback }); } -// We could piggieback on the Scroll mixin, but it is not applied -// consistently to all pages -// -// We try to keep this as cheap as possible by performing absolute minimal -// amount of work when the event handler is fired -// -// An alternative would be to use a timer that looks at the scroll position -// however this will not work as message bus can issue page updates and scroll -// page around when user is not present -// -// We avoid tracking mouse move which would be very expensive +export function setTestPresence(value) { + if (!isTesting()) { + throw "Only available in test mode"; + } + testPresence = value; +} -$(document).bind("touchmove.discourse-track-presence", seenUser); -$(document).bind("click.discourse-track-presence", seenUser); -$(window).bind("scroll.discourse-track-presence", seenUser); +export function clearPresenceCallbacks() { + callbacks.splice(0, callbacks.length); +} + +if (!isTesting()) { + // Some of these events occur very frequently. Therefore seenUser() is as fast as possible. + document.addEventListener("touchmove", seenUser, { passive: true }); + document.addEventListener("click", seenUser, { passive: true }); + window.addEventListener("scroll", seenUser, { passive: true }); + window.addEventListener("focus", seenUser, { passive: true }); + + document.addEventListener("visibilitychange", visibilityChanged, { + passive: true, + }); + + setInterval(processChanges, 10000); +} diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index e075a40f81..c6a5ca1446 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -5,6 +5,7 @@ import { deepMerge } from "discourse-common/lib/object"; import { escape } from "pretty-text/sanitizer"; import { helperContext } from "discourse-common/lib/helpers"; import toMarkdown from "discourse/lib/to-markdown"; +import deprecated from "discourse-common/lib/deprecated"; let _defaultHomepage; @@ -306,10 +307,6 @@ export function isAppleDevice() { let iPadDetected = undefined; -export function iOSWithVisualViewport() { - return isAppleDevice() && window.visualViewport !== undefined; -} - export function isiPad() { if (iPadDetected === undefined) { iPadDetected = @@ -320,16 +317,15 @@ export function isiPad() { } export function safariHacksDisabled() { - if (iOSWithVisualViewport()) { - return false; - } + deprecated( + "`safariHacksDisabled()` is deprecated, it now always returns `false`", + { + since: "2.8.0.beta8", + dropFrom: "2.9.0.beta1", + } + ); - let pref = localStorage.getItem("safari-hacks-disabled"); - let result = false; - if (pref !== null) { - result = pref === "true"; - } - return result; + return false; } const toArray = (items) => { @@ -478,9 +474,9 @@ export function inCodeBlock(text, pos) { } export function translateModKey(string) { - const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); - // Mac users are used to glyphs for shortcut keys - if (mac) { + const { isApple } = helperContext().capabilities; + // Apple device users are used to glyphs for shortcut keys + if (isApple) { string = string .replace("Shift", "\u21E7") .replace("Meta", "\u2318") diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js index 5146583d40..8db042cc1d 100644 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js +++ b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js @@ -1,5 +1,6 @@ import Mixin from "@ember/object/mixin"; import ExtendableUploader from "discourse/mixins/extendable-uploader"; +import EmberObject from "@ember/object"; import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart"; import { deepMerge } from "discourse-common/lib/object"; import UppyChecksum from "discourse/lib/uppy-checksum-plugin"; @@ -33,8 +34,14 @@ import bootbox from "bootbox"; // functionality and event binding. // export default Mixin.create(ExtendableUploader, UppyS3Multipart, { + uploadRootPath: "/uploads", uploadTargetBound: false, + @bind + _cancelSingleUpload(data) { + this._uppyInstance.removeFile(data.fileId); + }, + @observes("composerModel.uploadCancelled") _cancelUpload() { if (!this.get("composerModel.uploadCancelled")) { @@ -60,6 +67,10 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { this.element.removeEventListener("paste", this.pasteEventListener); this.appEvents.off(`${this.eventPrefix}:add-files`, this._addFiles); + this.appEvents.off( + `${this.eventPrefix}:cancel-upload`, + this._cancelSingleUpload + ); this._reset(); @@ -78,13 +89,17 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { }, _bindUploadTarget() { + this.set("inProgressUploads", []); this.placeholders = {}; - this._inProgressUploads = 0; this._preProcessorStatus = {}; this.fileInputEl = document.getElementById(this.fileUploadElementId); const isPrivateMessage = this.get("composerModel.privateMessage"); this.appEvents.on(`${this.eventPrefix}:add-files`, this._addFiles); + this.appEvents.on( + `${this.eventPrefix}:cancel-upload`, + this._cancelSingleUpload + ); this._unbindUploadTarget(); this.fileInputEventListener = bindFileInputChangeListener( @@ -125,24 +140,47 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { }, onBeforeUpload: (files) => { - const fileCount = Object.keys(files).length; const maxFiles = this.siteSettings.simultaneous_uploads; // Look for a matching file upload handler contributed from a plugin. - // It is not ideal that this only works for single file uploads, but - // at this time it is all we need. In future we may want to devise a - // nicer way of doing this. Uppy plugins are out of the question because - // there is no way to define which uploader plugin handles which file - // extensions at this time. - if (fileCount === 1) { - const file = Object.values(files)[0]; + // In future we may want to devise a nicer way of doing this. + // Uppy plugins are out of the question because there is no way to + // define which uploader plugin handles which file extensions at this time. + const unhandledFiles = {}; + const handlerBuckets = {}; + + for (const [fileId, file] of Object.entries(files)) { const matchingHandler = this._findMatchingUploadHandler(file.name); - if (matchingHandler && !matchingHandler.method(file.data, this)) { + if (matchingHandler) { + // the function signature will be converted to a string for the + // object key, so we can send multiple files at once to each handler + if (handlerBuckets[matchingHandler.method]) { + handlerBuckets[matchingHandler.method].files.push(file); + } else { + handlerBuckets[matchingHandler.method] = { + fn: matchingHandler.method, + // file.data is the native File object, which is all the plugins + // should need, not the uppy wrapper + files: [file.data], + }; + } + } else { + unhandledFiles[fileId] = { ...files[fileId] }; + } + } + + // Send the collected array of files to each matching handler, + // rather than the old jQuery file uploader method of sending + // a single file at a time through to the handler. + for (const bucket of Object.values(handlerBuckets)) { + if (!bucket.fn(bucket.files, this)) { return this._abortAndReset(); } } - // Limit the number of simultaneous uploads + // Limit the number of simultaneous uploads, for files which have + // _not_ been handled by an upload handler. + const fileCount = Object.keys(unhandledFiles).length; if (maxFiles > 0 && fileCount > maxFiles) { bootbox.alert( I18n.t("post.errors.too_many_dragged_and_dropped_files", { @@ -151,6 +189,9 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { ); return this._abortAndReset(); } + + // uppy uses this new object to track progress of remaining files + return unhandledFiles; }, }); @@ -180,6 +221,37 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { this.set("uploadProgress", progress); }); + this._uppyInstance.on("file-removed", (file, reason) => { + // we handle the cancel-all event specifically, so no need + // to do anything here. this event is also fired when some files + // are handled by an upload handler + if (reason === "cancel-all") { + return; + } + + file.meta.cancelled = true; + this._removeInProgressUpload(file.id); + this._resetUpload(file, { removePlaceholder: true }); + if (this.inProgressUploads.length === 0) { + this.set("userCancelled", true); + this._uppyInstance.cancelAll(); + } + }); + + this._uppyInstance.on("upload-progress", (file, progress) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + const upload = this.inProgressUploads.find((upl) => upl.id === file.id); + if (upload) { + const percentage = Math.round( + (progress.bytesUploaded / progress.bytesTotal) * 100 + ); + upload.set("progress", percentage); + } + }); + this._uppyInstance.on("upload", (data) => { this._addNeedProcessing(data.fileIDs.length); @@ -193,7 +265,16 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { }); files.forEach((file) => { - this._inProgressUploads++; + // The inProgressUploads is meant to be used to display these uploads + // in a UI, and Ember will only update the array in the UI if pushObject + // is used to notify it. + this.inProgressUploads.pushObject( + EmberObject.create({ + fileName: file.name, + id: file.id, + progress: 0, + }) + ); const placeholder = this._uploadPlaceholder(file); this.placeholders[file.id] = { uploadPlaceholder: placeholder, @@ -204,7 +285,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { }); this._uppyInstance.on("upload-success", (file, response) => { - this._inProgressUploads--; + this._removeInProgressUpload(file.id); let upload = response.body; const markdown = this.uploadMarkdownResolvers.reduce( (md, resolver) => resolver(upload) || md, @@ -261,7 +342,7 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { @bind _handleUploadError(file, error, response) { - this._inProgressUploads--; + this._removeInProgressUpload(file.id); this._resetUpload(file, { removePlaceholder: true }); file.meta.error = error; @@ -271,11 +352,18 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { this.appEvents.trigger(`${this.eventPrefix}:upload-error`, file); } - if (this._inProgressUploads === 0) { + if (this.inProgressUploads.length === 0) { this._reset(); } }, + _removeInProgressUpload(fileId) { + this.set( + "inProgressUploads", + this.inProgressUploads.filter((upl) => upl.id !== fileId) + ); + }, + _setupPreProcessors() { const checksumPreProcessor = { pluginClass: UppyChecksum, @@ -466,4 +554,35 @@ export default Mixin.create(ExtendableUploader, UppyS3Multipart, { showUploadSelector(toolbarEvent) { this.send("showUploadSelector", toolbarEvent); }, + + _bindMobileUploadButton() { + if (this.site.mobileView) { + this.mobileUploadButton = document.getElementById( + this.mobileFileUploaderId + ); + this.mobileUploadButtonEventListener = () => { + document.getElementById(this.fileUploadElementId).click(); + }; + this.mobileUploadButton.addEventListener( + "click", + this.mobileUploadButtonEventListener, + false + ); + } + }, + + _unbindMobileUploadButton() { + this.mobileUploadButton?.removeEventListener( + "click", + this.mobileUploadButtonEventListener + ); + }, + + _filenamePlaceholder(data) { + return data.name.replace(/\u200B-\u200D\uFEFF]/g, ""); + }, + + _resetUploadFilenamePlaceholder() { + this.set("uploadFilenamePlaceholder", null); + }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload.js b/app/assets/javascripts/discourse/app/mixins/composer-upload.js deleted file mode 100644 index cee453f6d0..0000000000 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload.js +++ /dev/null @@ -1,367 +0,0 @@ -import Mixin from "@ember/object/mixin"; -import I18n from "I18n"; -import { next, run } from "@ember/runloop"; -import getURL from "discourse-common/lib/get-url"; -import { clipboardHelpers } from "discourse/lib/utilities"; -import discourseComputed, { - observes, - on, -} from "discourse-common/utils/decorators"; -import { - displayErrorForUpload, - getUploadMarkdown, - validateUploadedFiles, -} from "discourse/lib/uploads"; -import { cacheShortUploadUrl } from "pretty-text/upload-short-url"; -import bootbox from "bootbox"; - -export default Mixin.create({ - _xhr: null, - uploadProgress: 0, - uploadFilenamePlaceholder: null, - uploadProcessingFilename: null, - uploadProcessingPlaceholdersAdded: false, - - @discourseComputed("uploadFilenamePlaceholder") - uploadPlaceholder(uploadFilenamePlaceholder) { - const clipboard = I18n.t("clipboard"); - const filename = uploadFilenamePlaceholder - ? uploadFilenamePlaceholder - : clipboard; - - let placeholder = `[${I18n.t("uploading_filename", { filename })}]()\n`; - if (!this._cursorIsOnEmptyLine()) { - placeholder = `\n${placeholder}`; - } - - return placeholder; - }, - - @observes("composer.uploadCancelled") - _cancelUpload() { - if (!this.get("composer.uploadCancelled")) { - return; - } - this.set("composer.uploadCancelled", false); - - if (this._xhr) { - this._xhr._userCancelled = true; - this._xhr.abort(); - } - this._resetUpload(true); - }, - - _setUploadPlaceholderSend(data) { - const filename = this._filenamePlaceholder(data); - this.set("uploadFilenamePlaceholder", filename); - - // when adding two separate files with the same filename search for matching - // placeholder already existing in the editor ie [Uploading: test.png...] - // and add order nr to the next one: [Uploading: test.png(1)...] - const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regexString = `\\[${I18n.t("uploading_filename", { - filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?", - })}\\]\\(\\)`; - const globalRegex = new RegExp(regexString, "g"); - const matchingPlaceholder = this.get("composer.reply").match(globalRegex); - if (matchingPlaceholder) { - // get last matching placeholder and its consecutive nr in regex - // capturing group and apply +1 to the placeholder - const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1]; - const regex = new RegExp(regexString); - const orderNr = regex.exec(lastMatch)[1] - ? parseInt(regex.exec(lastMatch)[1], 10) + 1 - : 1; - data.orderNr = orderNr; - const filenameWithOrderNr = `${filename}(${orderNr})`; - this.set("uploadFilenamePlaceholder", filenameWithOrderNr); - } - }, - - _setUploadPlaceholderDone(data) { - const filename = this._filenamePlaceholder(data); - - if (data.orderNr) { - const filenameWithOrderNr = `${filename}(${data.orderNr})`; - this.set("uploadFilenamePlaceholder", filenameWithOrderNr); - } else { - this.set("uploadFilenamePlaceholder", filename); - } - }, - - _filenamePlaceholder(data) { - if (data.files) { - return data.files[0].name.replace(/\u200B-\u200D\uFEFF]/g, ""); - } else { - return data.name.replace(/\u200B-\u200D\uFEFF]/g, ""); - } - }, - - _resetUploadFilenamePlaceholder() { - this.set("uploadFilenamePlaceholder", null); - }, - - _resetUpload(removePlaceholder) { - next(() => { - if (this._validUploads > 0) { - this._validUploads--; - } - if (this._validUploads === 0) { - this.setProperties({ - uploadProgress: 0, - isUploading: false, - isCancellable: false, - }); - } - if (removePlaceholder) { - this.appEvents.trigger( - "composer:replace-text", - this.uploadPlaceholder, - "" - ); - } - this._resetUploadFilenamePlaceholder(); - }); - }, - - _bindUploadTarget() { - this._unbindUploadTarget(); // in case it's still bound, let's clean it up first - this._pasted = false; - - const $element = $(this.element); - - this.setProperties({ - uploadProgress: 0, - isUploading: false, - isProcessingUpload: false, - isCancellable: false, - }); - - $.blueimp.fileupload.prototype.processActions = this.uploadProcessorActions; - - $element.fileupload({ - url: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`), - dataType: "json", - pasteZone: $element, - processQueue: this.uploadProcessorQueue, - }); - - $element - .on("fileuploadprocessstart", () => { - this.setProperties({ - uploadProgress: 0, - isUploading: true, - isProcessingUpload: true, - isCancellable: false, - }); - }) - .on("fileuploadprocess", (e, data) => { - if (!this.uploadProcessingPlaceholdersAdded) { - data.originalFiles - .map((f) => f.name) - .forEach((f) => { - this.appEvents.trigger( - "composer:insert-text", - `[${I18n.t("processing_filename", { - filename: f, - })}]()\n` - ); - }); - this.uploadProcessingPlaceholdersAdded = true; - } - this.uploadProcessingFilename = data.files[data.index].name; - }) - .on("fileuploadprocessstop", () => { - this.setProperties({ - uploadProgress: 0, - isUploading: false, - isProcessingUpload: false, - isCancellable: false, - }); - this.uploadProcessingPlaceholdersAdded = false; - }); - - $element.on("fileuploadpaste", (e) => { - this._pasted = true; - - if (!$(".d-editor-input").is(":focus")) { - return; - } - - const { canUpload, canPasteHtml, types } = clipboardHelpers(e, { - siteSettings: this.siteSettings, - canUpload: true, - }); - - if (!canUpload || canPasteHtml || types.includes("text/plain")) { - e.preventDefault(); - } - }); - - $element.on("fileuploadsubmit", (e, data) => { - const max = this.siteSettings.simultaneous_uploads; - const fileCount = data.files.length; - - // Limit the number of simultaneous uploads - if (max > 0 && fileCount > max) { - bootbox.alert( - I18n.t("post.errors.too_many_dragged_and_dropped_files", { - count: max, - }) - ); - return false; - } - - // Look for a matching file upload handler contributed from a plugin - if (fileCount === 1) { - const file = data.files[0]; - const matchingHandler = this._findMatchingUploadHandler(file.name); - if (matchingHandler && !matchingHandler.method(file, this)) { - return false; - } - } - - // If no plugin, continue as normal - const isPrivateMessage = this.get("composer.privateMessage"); - - data.formData = { type: "composer" }; - if (isPrivateMessage) { - data.formData.for_private_message = true; - } - if (this._pasted) { - data.formData.pasted = true; - } - - const opts = { - user: this.currentUser, - siteSettings: this.siteSettings, - isPrivateMessage, - allowStaffToUploadAnyFileInPm: this.siteSettings - .allow_staff_to_upload_any_file_in_pm, - }; - - const isUploading = validateUploadedFiles(data.files, opts); - - run(() => { - this.setProperties({ uploadProgress: 0, isUploading }); - }); - - return isUploading; - }); - - $element.on("fileuploadprogressall", (e, data) => { - run(() => { - this.set( - "uploadProgress", - parseInt((data.loaded / data.total) * 100, 10) - ); - }); - }); - - $element.on("fileuploadsend", (e, data) => { - run(() => { - this._pasted = false; - this._validUploads++; - - this._setUploadPlaceholderSend(data); - - if (this.uploadProcessingFilename) { - this.appEvents.trigger( - "composer:replace-text", - `[${I18n.t("processing_filename", { - filename: this.uploadProcessingFilename, - })}]()`, - this.uploadPlaceholder.trim() - ); - this.uploadProcessingFilename = null; - } else { - this.appEvents.trigger( - "composer:insert-text", - this.uploadPlaceholder - ); - } - - if (data.xhr && data.originalFiles.length === 1) { - this.set("isCancellable", true); - this._xhr = data.xhr(); - } - }); - }); - - $element.on("fileuploaddone", (e, data) => { - run(() => { - let upload = data.result; - this._setUploadPlaceholderDone(data); - if (!this._xhr || !this._xhr._userCancelled) { - const markdown = this.uploadMarkdownResolvers.reduce( - (md, resolver) => resolver(upload) || md, - getUploadMarkdown(upload) - ); - - cacheShortUploadUrl(upload.short_url, upload); - this.appEvents.trigger( - "composer:replace-text", - this.uploadPlaceholder.trim(), - markdown - ); - this._resetUpload(false); - } else { - this._resetUpload(true); - } - }); - }); - - $element.on("fileuploadfail", (e, data) => { - run(() => { - this._setUploadPlaceholderDone(data); - this._resetUpload(true); - - const userCancelled = this._xhr && this._xhr._userCancelled; - this._xhr = null; - - if (!userCancelled) { - displayErrorForUpload(data, this.siteSettings, data.files[0].name); - } - }); - }); - }, - - _bindMobileUploadButton() { - if (this.site.mobileView) { - this.mobileUploadButton = document.getElementById( - this.mobileFileUploaderId - ); - this.mobileUploadButtonEventListener = () => { - document.getElementById(this.fileUploadElementId).click(); - }; - this.mobileUploadButton.addEventListener( - "click", - this.mobileUploadButtonEventListener, - false - ); - } - }, - - _unbindMobileUploadButton() { - this.mobileUploadButton?.removeEventListener( - "click", - this.mobileUploadButtonEventListener - ); - }, - - @on("willDestroyElement") - _unbindUploadTarget() { - this._validUploads = 0; - const $uploadTarget = $(this.element); - try { - $uploadTarget.fileupload("destroy"); - } catch (e) { - /* wasn't initialized yet */ - } - $uploadTarget.off(); - }, - - showUploadSelector(toolbarEvent) { - this.send("showUploadSelector", toolbarEvent); - }, -}); diff --git a/app/assets/javascripts/discourse/app/mixins/docking.js b/app/assets/javascripts/discourse/app/mixins/docking.js index e8de346cbc..e97638ae5a 100644 --- a/app/assets/javascripts/discourse/app/mixins/docking.js +++ b/app/assets/javascripts/discourse/app/mixins/docking.js @@ -32,8 +32,10 @@ export default Mixin.create({ didInsertElement() { this._super(...arguments); - window.addEventListener("scroll", this.queueDockCheck); - document.addEventListener("touchmove", this.queueDockCheck); + window.addEventListener("scroll", this.queueDockCheck, { passive: true }); + document.addEventListener("touchmove", this.queueDockCheck, { + passive: true, + }); // dockCheck might happen too early on full page refresh this._initialTimer = later(this, this.safeDockCheck, 50); diff --git a/app/assets/javascripts/discourse/app/mixins/pan-events.js b/app/assets/javascripts/discourse/app/mixins/pan-events.js index 0134647267..35d03d1903 100644 --- a/app/assets/javascripts/discourse/app/mixins/pan-events.js +++ b/app/assets/javascripts/discourse/app/mixins/pan-events.js @@ -33,10 +33,13 @@ export default Mixin.create({ this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e); this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e); - element.addEventListener("touchstart", this.touchStart); - element.addEventListener("touchmove", this.touchMove); - element.addEventListener("touchend", this.touchEnd); - element.addEventListener("touchcancel", this.touchCancel); + const opts = { + passive: false, + }; + element.addEventListener("touchstart", this.touchStart, opts); + element.addEventListener("touchmove", this.touchMove, opts); + element.addEventListener("touchend", this.touchEnd, opts); + element.addEventListener("touchcancel", this.touchCancel, opts); } }, diff --git a/app/assets/javascripts/discourse/app/mixins/scrolling.js b/app/assets/javascripts/discourse/app/mixins/scrolling.js index 2f0a588e21..ced98a406f 100644 --- a/app/assets/javascripts/discourse/app/mixins/scrolling.js +++ b/app/assets/javascripts/discourse/app/mixins/scrolling.js @@ -1,6 +1,5 @@ import Mixin from "@ember/object/mixin"; -import discourseDebounce from "discourse-common/lib/debounce"; -import { scheduleOnce } from "@ember/runloop"; +import { scheduleOnce, throttle } from "@ember/runloop"; import { inject as service } from "@ember/service"; /** @@ -9,20 +8,18 @@ import { inject as service } from "@ember/service"; easier. **/ const ScrollingDOMMethods = { - bindOnScroll(onScrollMethod, name) { - name = name || "default"; - $(document).bind(`touchmove.discourse-${name}`, onScrollMethod); - $(window).bind(`scroll.discourse-${name}`, onScrollMethod); + bindOnScroll(onScrollMethod) { + document.addEventListener("touchmove", onScrollMethod, { passive: true }); + window.addEventListener("scroll", onScrollMethod, { passive: true }); }, - unbindOnScroll(name) { - name = name || "default"; - $(window).unbind(`scroll.discourse-${name}`); - $(document).unbind(`touchmove.discourse-${name}`); + unbindOnScroll(onScrollMethod) { + document.removeEventListener("touchmove", onScrollMethod); + window.removeEventListener("scroll", onScrollMethod); }, screenNotFull() { - return $(window).height() > $("#main").height(); + return window.height > document.querySelector("#main").offsetHeight; }, }; @@ -30,14 +27,15 @@ const Scrolling = Mixin.create({ router: service(), // Begin watching for scroll events. By default they will be called at max every 100ms. - // call with {debounce: N} for a diff time - bindScrolling(opts) { - opts = opts || { debounce: 100 }; - + // call with {throttle: N} to change the throttle spacing + bindScrolling(opts = {}) { + if (!opts.throttle) { + opts.throttle = 100; + } // So we can not call the scrolled event while transitioning. There is no public API for this :'( const microLib = this.router._router._routerMicrolib; - let onScrollMethod = () => { + let scheduleScrolled = () => { if (microLib.activeTransition) { return; } @@ -45,20 +43,22 @@ const Scrolling = Mixin.create({ return scheduleOnce("afterRender", this, "scrolled"); }; - if (opts.debounce) { - let debouncedScrollMethod = () => { - discourseDebounce(this, onScrollMethod, opts.debounce); - }; - ScrollingDOMMethods.bindOnScroll(debouncedScrollMethod, opts.name); + let onScrollMethod; + if (opts.throttle) { + onScrollMethod = () => + throttle(this, scheduleScrolled, opts.throttle, false); } else { - ScrollingDOMMethods.bindOnScroll(onScrollMethod, opts.name); + onScrollMethod = scheduleScrolled; } + + this._scrollingMixinOnScrollMethod = onScrollMethod; + ScrollingDOMMethods.bindOnScroll(onScrollMethod); }, screenNotFull: () => ScrollingDOMMethods.screenNotFull(), - unbindScrolling(name) { - ScrollingDOMMethods.unbindOnScroll(name); + unbindScrolling() { + ScrollingDOMMethods.unbindOnScroll(this._scrollingMixinOnScrollMethod); }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js index 5f229ace6b..6dd67f74ad 100644 --- a/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js +++ b/app/assets/javascripts/discourse/app/mixins/textarea-text-manipulation.js @@ -1,12 +1,14 @@ +import { bind } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; +import { generateLinkifyFunction } from "discourse/lib/text"; import toMarkdown from "discourse/lib/to-markdown"; +import { action } from "@ember/object"; +import { isEmpty } from "@ember/utils"; import { isTesting } from "discourse-common/config/environment"; import { clipboardHelpers, determinePostReplaceSelection, - safariHacksDisabled, } from "discourse/lib/utilities"; -import { bind } from "discourse-common/utils/decorators"; import { next, schedule } from "@ember/runloop"; const isInside = (text, regex) => { @@ -15,6 +17,14 @@ const isInside = (text, regex) => { }; export default Mixin.create({ + init() { + this._super(...arguments); + generateLinkifyFunction(this.markdownOptions || {}).then((linkify) => { + // When pasting links, we should use the same rules to match links as we do when creating links for a cooked post. + this._cachedLinkify = linkify; + }); + }, + // ensures textarea scroll position is correct _focusTextArea() { schedule("afterRender", () => { @@ -85,7 +95,7 @@ export default Mixin.create({ this._$textarea.trigger("change"); if (opts.scroll) { const oldScrollPos = this._$textarea.scrollTop(); - if (!this.capabilities.isIOS || safariHacksDisabled()) { + if (!this.capabilities.isIOS) { this._$textarea.focus(); } this._$textarea.scrollTop(oldScrollPos); @@ -240,7 +250,8 @@ export default Mixin.create({ let html = clipboard.getData("text/html"); let handled = false; - const { pre, lineVal } = this._getSelected(null, { lineVal: true }); + const selected = this._getSelected(null, { lineVal: true }); + const { pre, value: selectedValue, lineVal } = selected; const isInlinePasting = pre.match(/[^\n]$/); const isCodeBlock = isInside(pre, /(^|\n)```/g); @@ -270,6 +281,27 @@ export default Mixin.create({ } } + if ( + this._cachedLinkify && + plainText && + !handled && + selected.end > selected.start + ) { + if (this._cachedLinkify.test(plainText)) { + const match = this._cachedLinkify.match(plainText)[0]; + if ( + match && + match.index === 0 && + match.lastIndex === match.raw.length + ) { + // When specified, linkify supports fuzzy links and emails. Prefer providing the protocol. + // eg: pasting "example@discourse.org" may apply a link format of "mailto:example@discourse.org" + this._addText(selected, `[${selectedValue}](${match.url})`); + handled = true; + } + } + } + if (canPasteHtml && !handled) { let markdown = toMarkdown(html); @@ -288,4 +320,27 @@ export default Mixin.create({ e.preventDefault(); } }, + + @action + emojiSelected(code) { + let selected = this._getSelected(); + const captures = selected.pre.match(/\B:(\w*)$/); + + if (isEmpty(captures)) { + if (selected.pre.match(/\S$/)) { + this._addText(selected, ` :${code}:`); + } else { + this._addText(selected, `:${code}:`); + } + } else { + let numOfRemovedChars = selected.pre.length - captures[1].length; + selected.pre = selected.pre.slice( + 0, + selected.pre.length - captures[1].length + ); + selected.start -= numOfRemovedChars; + selected.end -= numOfRemovedChars; + this._addText(selected, `${code}:`); + } + }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js b/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js index 60ec47ad41..e2d75eb06c 100644 --- a/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js +++ b/app/assets/javascripts/discourse/app/mixins/uppy-s3-multipart.js @@ -1,13 +1,14 @@ import Mixin from "@ember/object/mixin"; +import getUrl from "discourse-common/lib/get-url"; +import { bind } from "discourse-common/utils/decorators"; import { Promise } from "rsvp"; import { ajax } from "discourse/lib/ajax"; import AwsS3Multipart from "@uppy/aws-s3-multipart"; +const RETRY_DELAYS = [0, 1000, 3000, 5000]; export default Mixin.create({ _useS3MultipartUploads() { this.set("usingS3MultipartUploads", true); - const self = this; - const retryDelays = [0, 1000, 3000, 5000]; this._uppyInstance.use(AwsS3Multipart, { // controls how many simultaneous _chunks_ are uploaded, not files, @@ -18,142 +19,160 @@ export default Mixin.create({ // chunk size via getChunkSize(file), so we may want to increase // the chunk size for larger files limit: 10, - retryDelays, + retryDelays: RETRY_DELAYS, - createMultipartUpload(file) { - self._uppyInstance.emit("create-multipart", file.id); - - const data = { - file_name: file.name, - file_size: file.size, - upload_type: file.meta.upload_type, - metadata: file.meta, - }; - - // the sha1 checksum is set by the UppyChecksum plugin, except - // for in cases where the browser does not support the required - // crypto mechanisms or an error occurs. it is an additional layer - // of security, and not required. - if (file.meta.sha1_checksum) { - data.metadata = { "sha1-checksum": file.meta.sha1_checksum }; - } - - return ajax("/uploads/create-multipart.json", { - type: "POST", - data, - // uppy is inconsistent, an error here fires the upload-error event - }).then((responseData) => { - self._uppyInstance.emit("create-multipart-success", file.id); - - file.meta.unique_identifier = responseData.unique_identifier; - return { - uploadId: responseData.external_upload_identifier, - key: responseData.key, - }; - }); - }, - - prepareUploadParts(file, partData) { - if (file.preparePartsRetryAttempts === undefined) { - file.preparePartsRetryAttempts = 0; - } - return ajax("/uploads/batch-presign-multipart-parts.json", { - type: "POST", - data: { - part_numbers: partData.partNumbers, - unique_identifier: file.meta.unique_identifier, - }, - }) - .then((data) => { - if (file.preparePartsRetryAttempts) { - delete file.preparePartsRetryAttempts; - self._consoleDebug( - `[uppy] Retrying batch fetch for ${file.id} was successful, continuing.` - ); - } - return { presignedUrls: data.presigned_urls }; - }) - .catch((err) => { - const status = err.jqXHR.status; - - // it is kind of ugly to have to track the retry attempts for - // the file based on the retry delays, but uppy's `retryable` - // function expects the rejected Promise data to be structured - // _just so_, and provides no interface for us to tell how many - // times the upload has been retried (which it tracks internally) - // - // if we exceed the attempts then there is no way that uppy will - // retry the upload once again, so in that case the alert can - // be safely shown to the user that their upload has failed. - if (file.preparePartsRetryAttempts < retryDelays.length) { - file.preparePartsRetryAttempts += 1; - const attemptsLeft = - retryDelays.length - file.preparePartsRetryAttempts + 1; - self._consoleDebug( - `[uppy] Fetching a batch of upload part URLs for ${file.id} failed with status ${status}, retrying ${attemptsLeft} more times...` - ); - return Promise.reject({ source: { status } }); - } else { - self._consoleDebug( - `[uppy] Fetching a batch of upload part URLs for ${file.id} failed too many times, throwing error.` - ); - // uppy is inconsistent, an error here does not fire the upload-error event - self._handleUploadError(file, err); - } - }); - }, - - completeMultipartUpload(file, data) { - self._uppyInstance.emit("complete-multipart", file.id); - const parts = data.parts.map((part) => { - return { part_number: part.PartNumber, etag: part.ETag }; - }); - return ajax("/uploads/complete-multipart.json", { - type: "POST", - contentType: "application/json", - data: JSON.stringify({ - parts, - unique_identifier: file.meta.unique_identifier, - pasted: file.meta.pasted, - for_private_message: file.meta.for_private_message, - }), - // uppy is inconsistent, an error here fires the upload-error event - }).then((responseData) => { - self._uppyInstance.emit("complete-multipart-success", file.id); - return responseData; - }); - }, - - abortMultipartUpload(file, { key, uploadId }) { - // if the user cancels the upload before the key and uploadId - // are stored from the createMultipartUpload response then they - // will not be set, and we don't have to abort the upload because - // it will not exist yet - if (!key || !uploadId) { - return; - } - - // this gives us a chance to inspect the upload stub before - // it is deleted from external storage by aborting the multipart - // upload; see also ExternalUploadManager - if (file.meta.error && self.siteSettings.enable_upload_debug_mode) { - return; - } - - return ajax("/uploads/abort-multipart.json", { - type: "POST", - data: { - external_upload_identifier: uploadId, - }, - // uppy is inconsistent, an error here does not fire the upload-error event - }).catch((err) => { - self._handleUploadError(file, err); - }); - }, + createMultipartUpload: this._createMultipartUpload, + prepareUploadParts: this._prepareUploadParts, + completeMultipartUpload: this._completeMultipartUpload, + abortMultipartUpload: this._abortMultipartUpload, // we will need a listParts function at some point when we want to // resume multipart uploads; this is used by uppy to figure out // what parts are uploaded and which still need to be }); }, + + @bind + _createMultipartUpload(file) { + this._uppyInstance.emit("create-multipart", file.id); + + const data = { + file_name: file.name, + file_size: file.size, + upload_type: file.meta.upload_type, + metadata: file.meta, + }; + + // the sha1 checksum is set by the UppyChecksum plugin, except + // for in cases where the browser does not support the required + // crypto mechanisms or an error occurs. it is an additional layer + // of security, and not required. + if (file.meta.sha1_checksum) { + data.metadata = { "sha1-checksum": file.meta.sha1_checksum }; + } + + return ajax(getUrl(`${this.uploadRootPath}/create-multipart.json`), { + type: "POST", + data, + // uppy is inconsistent, an error here fires the upload-error event + }).then((responseData) => { + this._uppyInstance.emit("create-multipart-success", file.id); + + file.meta.unique_identifier = responseData.unique_identifier; + return { + uploadId: responseData.external_upload_identifier, + key: responseData.key, + }; + }); + }, + + @bind + _prepareUploadParts(file, partData) { + if (file.preparePartsRetryAttempts === undefined) { + file.preparePartsRetryAttempts = 0; + } + return ajax( + getUrl(`${this.uploadRootPath}/batch-presign-multipart-parts.json`), + { + type: "POST", + data: { + part_numbers: partData.partNumbers, + unique_identifier: file.meta.unique_identifier, + }, + } + ) + .then((data) => { + if (file.preparePartsRetryAttempts) { + delete file.preparePartsRetryAttempts; + this._consoleDebug( + `[uppy] Retrying batch fetch for ${file.id} was successful, continuing.` + ); + } + return { presignedUrls: data.presigned_urls }; + }) + .catch((err) => { + const status = err.jqXHR.status; + + // it is kind of ugly to have to track the retry attempts for + // the file based on the retry delays, but uppy's `retryable` + // function expects the rejected Promise data to be structured + // _just so_, and provides no interface for us to tell how many + // times the upload has been retried (which it tracks internally) + // + // if we exceed the attempts then there is no way that uppy will + // retry the upload once again, so in that case the alert can + // be safely shown to the user that their upload has failed. + if (file.preparePartsRetryAttempts < RETRY_DELAYS.length) { + file.preparePartsRetryAttempts += 1; + const attemptsLeft = + RETRY_DELAYS.length - file.preparePartsRetryAttempts + 1; + this._consoleDebug( + `[uppy] Fetching a batch of upload part URLs for ${file.id} failed with status ${status}, retrying ${attemptsLeft} more times...` + ); + return Promise.reject({ source: { status } }); + } else { + this._consoleDebug( + `[uppy] Fetching a batch of upload part URLs for ${file.id} failed too many times, throwing error.` + ); + // uppy is inconsistent, an error here does not fire the upload-error event + this._handleUploadError(file, err); + } + }); + }, + + @bind + _completeMultipartUpload(file, data) { + if (file.meta.cancelled) { + return; + } + + this._uppyInstance.emit("complete-multipart", file.id); + const parts = data.parts.map((part) => { + return { part_number: part.PartNumber, etag: part.ETag }; + }); + return ajax(getUrl(`${this.uploadRootPath}/complete-multipart.json`), { + type: "POST", + contentType: "application/json", + data: JSON.stringify({ + parts, + unique_identifier: file.meta.unique_identifier, + pasted: file.meta.pasted, + for_private_message: file.meta.for_private_message, + }), + // uppy is inconsistent, an error here fires the upload-error event + }).then((responseData) => { + this._uppyInstance.emit("complete-multipart-success", file.id); + return responseData; + }); + }, + + @bind + _abortMultipartUpload(file, { key, uploadId }) { + // if the user cancels the upload before the key and uploadId + // are stored from the createMultipartUpload response then they + // will not be set, and we don't have to abort the upload because + // it will not exist yet + if (!key || !uploadId) { + return; + } + + // this gives us a chance to inspect the upload stub before + // it is deleted from external storage by aborting the multipart + // upload; see also ExternalUploadManager + if (file.meta.error && this.siteSettings.enable_upload_debug_mode) { + return; + } + + file.meta.cancelled = true; + + return ajax(getUrl(`${this.uploadRootPath}/abort-multipart.json`), { + type: "POST", + data: { + external_upload_identifier: uploadId, + }, + // uppy is inconsistent, an error here does not fire the upload-error event + }).catch((err) => { + this._handleUploadError(file, err); + }); + }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js index 3fe51eae6f..84ca638dfb 100644 --- a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js +++ b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js @@ -1,4 +1,5 @@ import Mixin from "@ember/object/mixin"; +import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { bindFileInputChangeListener, @@ -14,6 +15,7 @@ import XHRUpload from "@uppy/xhr-upload"; import AwsS3 from "@uppy/aws-s3"; import UppyChecksum from "discourse/lib/uppy-checksum-plugin"; import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart"; +import UppyChunkedUploader from "discourse/lib/uppy-chunked-uploader-plugin"; import { on } from "discourse-common/utils/decorators"; import { warn } from "@ember/debug"; import bootbox from "bootbox"; @@ -25,8 +27,10 @@ export default Mixin.create(UppyS3Multipart, { uploadProgress: 0, _uppyInstance: null, autoStartUploads: true, - _inProgressUploads: 0, + inProgressUploads: null, id: null, + uploadRootPath: "/uploads", + fileInputSelector: ".hidden-upload-field", uploadDone() { warn("You should implement `uploadDone`", { @@ -54,9 +58,10 @@ export default Mixin.create(UppyS3Multipart, { @on("didInsertElement") _initialize() { this.setProperties({ - fileInputEl: this.element.querySelector(".hidden-upload-field"), + fileInputEl: this.element.querySelector(this.fileInputSelector), }); this.set("allowMultipleFiles", this.fileInputEl.multiple); + this.set("inProgressUploads", []); this._bindFileInputChange(); @@ -92,7 +97,11 @@ export default Mixin.create(UppyS3Multipart, { this.validateUploadedFilesOptions() ); const isValid = validateUploadedFile(currentFile, validationOpts); - this.setProperties({ uploadProgress: 0, uploading: isValid }); + this.setProperties({ + uploadProgress: 0, + uploading: isValid && this.autoStartUploads, + filesAwaitingUpload: !this.autoStartUploads, + }); return isValid; }, @@ -119,6 +128,13 @@ export default Mixin.create(UppyS3Multipart, { this._reset(); return false; } + + // for a single file, we want to override file meta with the + // data property (which may be computed), to override any keys + // specified by this.data (such as name) + if (fileCount === 1) { + deepMerge(Object.values(files)[0].meta, this.data); + } }, }); @@ -134,37 +150,53 @@ export default Mixin.create(UppyS3Multipart, { }); this._uppyInstance.on("upload", (data) => { - this._inProgressUploads += data.fileIDs.length; + const files = data.fileIDs.map((fileId) => + this._uppyInstance.getFile(fileId) + ); + files.forEach((file) => { + this.inProgressUploads.push( + EmberObject.create({ + fileName: file.name, + id: file.id, + progress: 0, + }) + ); + }); }); this._uppyInstance.on("upload-success", (file, response) => { - this._inProgressUploads--; + this._removeInProgressUpload(file.id); if (this.usingS3Uploads) { this.setProperties({ uploading: false, processing: true }); this._completeExternalUpload(file) .then((completeResponse) => { - this.uploadDone(completeResponse); + this.uploadDone( + deepMerge(completeResponse, { file_name: file.name }) + ); - if (this._inProgressUploads === 0) { + if (this.inProgressUploads.length === 0) { this._reset(); } }) .catch((errResponse) => { displayErrorForUpload(errResponse, this.siteSettings, file.name); - if (this._inProgressUploads === 0) { + if (this.inProgressUploads.length === 0) { this._reset(); } }); } else { - this.uploadDone(response.body); - if (this._inProgressUploads === 0) { + this.uploadDone( + deepMerge(response?.body || {}, { file_name: file.name }) + ); + if (this.inProgressUploads.length === 0) { this._reset(); } } }); this._uppyInstance.on("upload-error", (file, error, response) => { + this._removeInProgressUpload(file.id); displayErrorForUpload(response || error, this.siteSettings, file.name); this._reset(); }); @@ -177,7 +209,8 @@ export default Mixin.create(UppyS3Multipart, { // allow these other uploaders to go direct to S3. if ( this.siteSettings.enable_direct_s3_uploads && - !this.preventDirectS3Uploads + !this.preventDirectS3Uploads && + !this.useChunkedUploads ) { if (this.useMultipartUploadsIfAvailable) { this._useS3MultipartUploads(); @@ -185,10 +218,24 @@ export default Mixin.create(UppyS3Multipart, { this._useS3Uploads(); } } else { - this._useXHRUploads(); + if (this.useChunkedUploads) { + this._useChunkedUploads(); + } else { + this._useXHRUploads(); + } } }, + _startUpload() { + if (!this.filesAwaitingUpload) { + return; + } + if (!this._uppyInstance?.getFiles().length) { + return; + } + return this._uppyInstance?.upload(); + }, + _useXHRUploads() { this._uppyInstance.use(XHRUpload, { endpoint: this._xhrUploadUrl(), @@ -198,6 +245,16 @@ export default Mixin.create(UppyS3Multipart, { }); }, + _useChunkedUploads() { + this.set("usingChunkedUploads", true); + this._uppyInstance.use(UppyChunkedUploader, { + url: this._xhrUploadUrl(), + headers: { + "X-CSRF-Token": this.session.csrfToken, + }, + }); + }, + _useS3Uploads() { this.set("usingS3Uploads", true); this._uppyInstance.use(AwsS3, { @@ -216,7 +273,7 @@ export default Mixin.create(UppyS3Multipart, { data.metadata = { "sha1-checksum": file.meta.sha1_checksum }; } - return ajax(getUrl("/uploads/generate-presigned-put"), { + return ajax(getUrl(`${this.uploadRootPath}/generate-presigned-put`), { type: "POST", data, }) @@ -243,7 +300,7 @@ export default Mixin.create(UppyS3Multipart, { _xhrUploadUrl() { return ( - getUrl(this.getWithDefault("uploadUrl", "/uploads")) + + getUrl(this.getWithDefault("uploadUrl", this.uploadRootPath)) + ".json?client_id=" + this.messageBus?.clientId ); @@ -270,7 +327,7 @@ export default Mixin.create(UppyS3Multipart, { }, _completeExternalUpload(file) { - return ajax(getUrl("/uploads/complete-external-upload"), { + return ajax(getUrl(`${this.uploadRootPath}/complete-external-upload`), { type: "POST", data: deepMerge( { unique_identifier: file.meta.uniqueUploadIdentifier }, @@ -285,7 +342,15 @@ export default Mixin.create(UppyS3Multipart, { uploading: false, processing: false, uploadProgress: 0, + filesAwaitingUpload: false, }); this.fileInputEl.value = ""; }, + + _removeInProgressUpload(fileId) { + this.set( + "inProgressUploads", + this.inProgressUploads.filter((upl) => upl.id !== fileId) + ); + }, }); diff --git a/app/assets/javascripts/discourse/app/mixins/username-validation.js b/app/assets/javascripts/discourse/app/mixins/username-validation.js index 522b40ad5f..2e11cb75eb 100644 --- a/app/assets/javascripts/discourse/app/mixins/username-validation.js +++ b/app/assets/javascripts/discourse/app/mixins/username-validation.js @@ -86,6 +86,10 @@ export default Mixin.create({ checkUsernameAvailability() { return User.checkUsername(this.accountUsername, this.accountEmail).then( (result) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + this.set("isDeveloper", false); if (result.available) { if (result.is_developer) { diff --git a/app/assets/javascripts/discourse/app/models/bookmark.js b/app/assets/javascripts/discourse/app/models/bookmark.js index 19c261afb3..bbe656d63b 100644 --- a/app/assets/javascripts/discourse/app/models/bookmark.js +++ b/app/assets/javascripts/discourse/app/models/bookmark.js @@ -1,4 +1,4 @@ -import Category from "discourse/models/category"; +import categoryFromId from "discourse-common/utils/category-macro"; import I18n from "I18n"; import { Promise } from "rsvp"; import RestModel from "discourse/models/rest"; @@ -119,10 +119,7 @@ const Bookmark = RestModel.extend({ return newTags; }, - @discourseComputed("category_id") - category(categoryId) { - return Category.findById(categoryId); - }, + category: categoryFromId("category_id"), @discourseComputed("reminder_at", "currentUser") formattedReminder(bookmarkReminderAt, currentUser) { diff --git a/app/assets/javascripts/discourse/app/models/login-method.js b/app/assets/javascripts/discourse/app/models/login-method.js index 113482d8e0..0c3b516873 100644 --- a/app/assets/javascripts/discourse/app/models/login-method.js +++ b/app/assets/javascripts/discourse/app/models/login-method.js @@ -15,7 +15,10 @@ const LoginMethod = EmberObject.extend({ @discourseComputed screenReaderTitle() { - return this.title_override || I18n.t(`login.${this.name}.sr_title`); + return ( + this.title_override || + I18n.t(`login.${this.name}.sr_title`, { defaultValue: this.title }) + ); }, @discourseComputed diff --git a/app/assets/javascripts/discourse/app/models/pending-post.js b/app/assets/javascripts/discourse/app/models/pending-post.js new file mode 100644 index 0000000000..65f723a3f7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/models/pending-post.js @@ -0,0 +1,28 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import RestModel from "discourse/models/rest"; +import categoryFromId from "discourse-common/utils/category-macro"; +import { userPath } from "discourse/lib/url"; +import { reads } from "@ember/object/computed"; +import { cookAsync } from "discourse/lib/text"; + +const PendingPost = RestModel.extend({ + expandedExcerpt: null, + postUrl: reads("topic_url"), + truncated: false, + + init() { + this._super(...arguments); + cookAsync(this.raw_text).then((cooked) => { + this.set("expandedExcerpt", cooked); + }); + }, + + @discourseComputed("username") + userUrl(username) { + return userPath(username.toLowerCase()); + }, + + category: categoryFromId("category_id"), +}); + +export default PendingPost; diff --git a/app/assets/javascripts/discourse/app/models/post-stream.js b/app/assets/javascripts/discourse/app/models/post-stream.js index c2b64b3579..9eeffd6c08 100644 --- a/app/assets/javascripts/discourse/app/models/post-stream.js +++ b/app/assets/javascripts/discourse/app/models/post-stream.js @@ -1215,17 +1215,21 @@ export default RestModel.extend({ // Handles an error loading a topic based on a HTTP status code. Updates // the text to the correct values. - errorLoading(result) { + errorLoading(error) { const topic = this.topic; this.set("loadingFilter", false); topic.set("errorLoading", true); - const json = result.jqXHR.responseJSON; + if (!error.jqXHR) { + throw error; + } + + const json = error.jqXHR.responseJSON; if (json && json.extras && json.extras.html) { topic.set("errorHtml", json.extras.html); } else { topic.set("errorMessage", I18n.t("topic.server_error.description")); - topic.set("noRetry", result.jqXHR.status === 403); + topic.set("noRetry", error.jqXHR.status === 403); } }, diff --git a/app/assets/javascripts/discourse/app/models/reviewable.js b/app/assets/javascripts/discourse/app/models/reviewable.js index a0ad27937a..d991f40b92 100644 --- a/app/assets/javascripts/discourse/app/models/reviewable.js +++ b/app/assets/javascripts/discourse/app/models/reviewable.js @@ -1,4 +1,4 @@ -import Category from "discourse/models/category"; +import categoryFromId from "discourse-common/utils/category-macro"; import I18n from "I18n"; import { Promise } from "rsvp"; import RestModel from "discourse/models/rest"; @@ -24,10 +24,7 @@ const Reviewable = RestModel.extend({ }); }, - @discourseComputed("category_id") - category(categoryId) { - return Category.findById(categoryId); - }, + category: categoryFromId("category_id"), update(updates) { // If no changes, do nothing diff --git a/app/assets/javascripts/discourse/app/models/site.js b/app/assets/javascripts/discourse/app/models/site.js index 9b92108149..50e9ee0196 100644 --- a/app/assets/javascripts/discourse/app/models/site.js +++ b/app/assets/javascripts/discourse/app/models/site.js @@ -57,30 +57,21 @@ const Site = RestModel.extend({ // Sort subcategories under parents @discourseComputed("categoriesByCount", "categories.[]") - sortedCategories(cats) { - const result = [], - remaining = {}; + sortedCategories(categories) { + const children = new Map(); - cats.forEach((c) => { - const parentCategoryId = parseInt(c.get("parent_category_id"), 10); - if (!parentCategoryId) { - result.pushObject(c); - } else { - remaining[parentCategoryId] = remaining[parentCategoryId] || []; - remaining[parentCategoryId].pushObject(c); - } + categories.forEach((category) => { + const parentId = parseInt(category.parent_category_id, 10) || -1; + const group = children.get(parentId) || []; + group.pushObject(category); + + children.set(parentId, group); }); - Object.keys(remaining).forEach((parentCategoryId) => { - const category = result.findBy("id", parseInt(parentCategoryId, 10)), - index = result.indexOf(category); + const reduce = (values) => + values.flatMap((c) => [c, reduce(children.get(c.id) || [])]).flat(); - if (index !== -1) { - result.replace(index + 1, 0, remaining[parentCategoryId]); - } - }); - - return result; + return reduce(children.get(-1)); }, // Returns it in the correct order, by setting diff --git a/app/assets/javascripts/discourse/app/models/store.js b/app/assets/javascripts/discourse/app/models/store.js index ffac278f1c..42523e3e76 100644 --- a/app/assets/javascripts/discourse/app/models/store.js +++ b/app/assets/javascripts/discourse/app/models/store.js @@ -1,383 +1,10 @@ -import EmberObject, { set } from "@ember/object"; -import { Promise } from "rsvp"; -import RestModel from "discourse/models/rest"; -import ResultSet from "discourse/models/result-set"; -import { ajax } from "discourse/lib/ajax"; -import { getRegister } from "discourse-common/lib/get-owner"; -import { underscore } from "@ember/string"; +import deprecated from "discourse-common/lib/deprecated"; +export { default, flushMap } from "discourse/services/store"; -let _identityMap; - -// You should only call this if you're a test scaffold -function flushMap() { - _identityMap = {}; -} - -function storeMap(type, id, obj) { - if (!id) { - return; +deprecated( + `"discourse/models/store" import is deprecated, use "discourse/services/store" instead`, + { + since: "2.8.0.beta8", + dropFrom: "2.9.0.beta1", } - - _identityMap[type] = _identityMap[type] || {}; - _identityMap[type][id] = obj; -} - -function fromMap(type, id) { - const byType = _identityMap[type]; - if (byType && byType.hasOwnProperty(id)) { - return byType[id]; - } -} - -function removeMap(type, id) { - const byType = _identityMap[type]; - if (byType && byType.hasOwnProperty(id)) { - delete byType[id]; - } -} - -function findAndRemoveMap(type, id) { - const byType = _identityMap[type]; - if (byType && byType.hasOwnProperty(id)) { - const result = byType[id]; - delete byType[id]; - return result; - } -} - -flushMap(); - -export default EmberObject.extend({ - _plurals: { - category: "categories", - "post-reply": "post-replies", - "post-reply-history": "post_reply_histories", - reviewable_history: "reviewable_histories", - }, - - init() { - this._super(...arguments); - this.register = this.register || getRegister(this); - }, - - pluralize(thing) { - return this._plurals[thing] || thing + "s"; - }, - - addPluralization(thing, plural) { - this._plurals[thing] = plural; - }, - - findAll(type, findArgs) { - const adapter = this.adapterFor(type); - - let store = this; - return adapter.findAll(this, type, findArgs).then((result) => { - let results = this._resultSet(type, result); - if (adapter.afterFindAll) { - results = adapter.afterFindAll(results, { - lookup(subType, id) { - return store._lookupSubType(subType, type, id, result); - }, - }); - } - return results; - }); - }, - - // Mostly for legacy, things like TopicList without ResultSets - findFiltered(type, findArgs) { - return this.adapterFor(type) - .find(this, type, findArgs) - .then((result) => this._build(type, result)); - }, - - _hydrateFindResults(result, type, findArgs) { - if (typeof findArgs === "object") { - return this._resultSet(type, result, findArgs); - } else { - const apiName = this.adapterFor(type).apiNameFor(type); - return this._hydrate(type, result[underscore(apiName)], result); - } - }, - - // See if the store can find stale data. We sometimes prefer to show stale data and - // refresh it in the background. - findStale(type, findArgs, opts) { - const stale = this.adapterFor(type).findStale(this, type, findArgs, opts); - return { - hasResults: stale !== undefined, - results: stale, - refresh: () => this.find(type, findArgs, opts), - }; - }, - - find(type, findArgs, opts) { - let adapter = this.adapterFor(type); - return adapter.find(this, type, findArgs, opts).then((result) => { - let hydrated = this._hydrateFindResults(result, type, findArgs, opts); - - if (result.extras) { - hydrated.set("extras", result.extras); - } - - if (adapter.cache) { - const stale = adapter.findStale(this, type, findArgs, opts); - hydrated = this._updateStale(stale, hydrated, adapter.primaryKey); - adapter.cacheFind(this, type, findArgs, opts, hydrated); - } - return hydrated; - }); - }, - - _updateStale(stale, hydrated, primaryKey) { - if (!stale) { - return hydrated; - } - - hydrated.set( - "content", - hydrated.get("content").map((item) => { - let staleItem = stale.content.findBy(primaryKey, item.get(primaryKey)); - if (staleItem) { - staleItem.setProperties(item); - } else { - staleItem = item; - } - return staleItem; - }) - ); - return hydrated; - }, - - refreshResults(resultSet, type, url) { - const adapter = this.adapterFor(type); - return ajax(url).then((result) => { - const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); - const content = result[typeName].map((obj) => - this._hydrate(type, obj, result) - ); - resultSet.set("content", content); - }); - }, - - appendResults(resultSet, type, url) { - const adapter = this.adapterFor(type); - return ajax(url).then((result) => { - const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); - - let pageTarget = result.meta || result; - let totalRows = - pageTarget["total_rows_" + typeName] || resultSet.get("totalRows"); - let loadMoreUrl = pageTarget["load_more_" + typeName]; - let content = result[typeName].map((obj) => - this._hydrate(type, obj, result) - ); - - resultSet.setProperties({ totalRows, loadMoreUrl }); - resultSet.get("content").pushObjects(content); - - // If we've loaded them all, clear the load more URL - if (resultSet.get("length") >= totalRows) { - resultSet.set("loadMoreUrl", null); - } - }); - }, - - update(type, id, attrs) { - const adapter = this.adapterFor(type); - return adapter.update(this, type, id, attrs, function (result) { - if (result && result[type] && result[type][adapter.primaryKey]) { - const oldRecord = findAndRemoveMap(type, id); - storeMap(type, result[type][adapter.primaryKey], oldRecord); - } - return result; - }); - }, - - createRecord(type, attrs) { - attrs = attrs || {}; - const adapter = this.adapterFor(type); - return !!attrs[adapter.primaryKey] - ? this._hydrate(type, attrs) - : this._build(type, attrs); - }, - - destroyRecord(type, record) { - const adapter = this.adapterFor(type); - - // If the record is new, don't perform an Ajax call - if (record.get("isNew")) { - removeMap(type, record.get(adapter.primaryKey)); - return Promise.resolve(true); - } - - return adapter.destroyRecord(this, type, record).then(function (result) { - removeMap(type, record.get(adapter.primaryKey)); - return result; - }); - }, - - _resultSet(type, result, findArgs) { - const adapter = this.adapterFor(type); - const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); - - if (!result[typeName]) { - // eslint-disable-next-line no-console - console.error(`JSON response is missing \`${typeName}\` key`, result); - return; - } - - const content = result[typeName].map((obj) => - this._hydrate(type, obj, result) - ); - - let pageTarget = result.meta || result; - - const createArgs = { - content, - findArgs, - totalRows: pageTarget["total_rows_" + typeName] || content.length, - loadMoreUrl: pageTarget["load_more_" + typeName], - refreshUrl: pageTarget["refresh_" + typeName], - resultSetMeta: result.meta, - store: this, - __type: type, - }; - - if (result.extras) { - createArgs.extras = result.extras; - } - - return ResultSet.create(createArgs); - }, - - _build(type, obj) { - const adapter = this.adapterFor(type); - obj.store = this; - obj.__type = type; - obj.__state = obj[adapter.primaryKey] ? "created" : "new"; - - // TODO: Have injections be automatic - obj.topicTrackingState = this.register.lookup("topic-tracking-state:main"); - obj.keyValueStore = this.register.lookup("key-value-store:main"); - - const klass = this.register.lookupFactory("model:" + type) || RestModel; - const model = klass.create(obj); - - storeMap(type, obj[adapter.primaryKey], model); - return model; - }, - - adapterFor(type) { - return ( - this.register.lookup("adapter:" + type) || - this.register.lookup("adapter:rest") - ); - }, - - _lookupSubType(subType, type, id, root) { - if (root.meta && root.meta.types) { - subType = root.meta.types[subType] || subType; - } - - const subTypeAdapter = this.adapterFor(subType); - const pluralType = this.pluralize(subType); - const collection = root[this.pluralize(subType)]; - if (collection) { - const hashedProp = "__hashed_" + pluralType; - let hashedCollection = root[hashedProp]; - if (!hashedCollection) { - hashedCollection = {}; - collection.forEach(function (it) { - hashedCollection[it[subTypeAdapter.primaryKey]] = it; - }); - root[hashedProp] = hashedCollection; - } - - const found = hashedCollection[id]; - if (found) { - const hydrated = this._hydrate(subType, found, root); - hashedCollection[id] = hydrated; - return hydrated; - } - } - }, - - _hydrateEmbedded(type, obj, root) { - const adapter = this.adapterFor(type); - Object.keys(obj).forEach((k) => { - if (k === adapter.primaryKey) { - return; - } - - const m = /(.+)\_id(s?)$/.exec(k); - if (m) { - const subType = m[1]; - - if (m[2]) { - const hydrated = obj[k].map((id) => - this._lookupSubType(subType, type, id, root) - ); - obj[this.pluralize(subType)] = hydrated || []; - delete obj[k]; - } else { - const hydrated = this._lookupSubType(subType, type, obj[k], root); - if (hydrated) { - obj[subType] = hydrated; - delete obj[k]; - } else { - set(obj, subType, null); - } - } - } - }); - }, - - _hydrate(type, obj, root) { - if (!obj) { - throw new Error("Can't hydrate " + type + " of `null`"); - } - - const adapter = this.adapterFor(type); - - const id = obj[adapter.primaryKey]; - if (!id) { - throw new Error( - `Can't hydrate ${type} without primaryKey: \`${adapter.primaryKey}\`` - ); - } - - root = root || obj; - - if (root.__rest_serializer === "1") { - this._hydrateEmbedded(type, obj, root); - } - - const existing = fromMap(type, id); - if (existing === obj) { - return existing; - } - - if (existing) { - delete obj[adapter.primaryKey]; - let klass = this.register.lookupFactory("model:" + type); - - if (klass && klass.class) { - klass = klass.class; - } - - if (!klass) { - klass = RestModel; - } - - existing.setProperties(klass.munge(obj)); - obj[adapter.primaryKey] = id; - return existing; - } - - return this._build(type, obj); - }, -}); - -export { flushMap }; +); diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 6883581b50..e17c723d29 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -1,7 +1,7 @@ import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import { fmt, propertyEqual } from "discourse/lib/computed"; import ActionSummary from "discourse/models/action-summary"; -import Category from "discourse/models/category"; +import categoryFromId from "discourse-common/utils/category-macro"; import Bookmark from "discourse/models/bookmark"; import EmberObject from "@ember/object"; import I18n from "I18n"; @@ -15,7 +15,7 @@ import { deepMerge } from "discourse-common/lib/object"; import discourseComputed from "discourse-common/utils/decorators"; import { emojiUnescape } from "discourse/lib/text"; import { fancyTitle } from "discourse/lib/topic-fancy-title"; -import { flushMap } from "discourse/models/store"; +import { flushMap } from "discourse/services/store"; import getURL from "discourse-common/lib/get-url"; import { longDate } from "discourse/lib/formatter"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -209,10 +209,7 @@ const Topic = RestModel.extend({ return { type: "topic", id }; }, - @discourseComputed("category_id") - category(categoryId) { - return Category.findById(categoryId); - }, + category: categoryFromId("category_id"), @discourseComputed("url") shareUrl(url) { @@ -493,7 +490,10 @@ const Topic = RestModel.extend({ keys.forEach((key) => this.set(key, json[key])); if (this.bookmarks.length) { - this.bookmarks = this.bookmarks.map((bm) => Bookmark.create(bm)); + this.set( + "bookmarks", + this.bookmarks.map((bm) => Bookmark.create(bm)) + ); } return this; diff --git a/app/assets/javascripts/discourse/app/models/user-action.js b/app/assets/javascripts/discourse/app/models/user-action.js index 2c3762c726..a1a567720e 100644 --- a/app/assets/javascripts/discourse/app/models/user-action.js +++ b/app/assets/javascripts/discourse/app/models/user-action.js @@ -1,6 +1,6 @@ import { and, equal, or } from "@ember/object/computed"; -import discourseComputed, { on } from "discourse-common/utils/decorators"; -import Category from "discourse/models/category"; +import discourseComputed from "discourse-common/utils/decorators"; +import categoryFromId from "discourse-common/utils/category-macro"; import RestModel from "discourse/models/rest"; import User from "discourse/models/user"; import UserActionGroup from "discourse/models/user-action-group"; @@ -19,7 +19,6 @@ const UserActionTypes = { edits: 11, messages_sent: 12, messages_received: 13, - pending: 14, }; const InvertedActionTypes = {}; @@ -28,13 +27,7 @@ Object.keys(UserActionTypes).forEach( ); const UserAction = RestModel.extend({ - @on("init") - _attachCategory() { - const categoryId = this.category_id; - if (categoryId) { - this.set("category", Category.findById(categoryId)); - } - }, + category: categoryFromId("category_id"), @discourseComputed("action_type") descriptionKey(action) { diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index 2876dd604b..21434572c8 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -1072,6 +1072,14 @@ User.reopenClass(Singleton, { return ajax(userPath("check_email"), { data: { email } }); }, + loadRecentSearches() { + return ajax(`/u/recent-searches`); + }, + + resetRecentSearches() { + return ajax(`/u/recent-searches`, { type: "DELETE" }); + }, + groupStats(stats) { const responses = UserActionStat.create({ count: 0, diff --git a/app/assets/javascripts/discourse/app/pre-initializers/inject-discourse-objects.js b/app/assets/javascripts/discourse/app/pre-initializers/inject-discourse-objects.js index 576fbc589e..6f82a8ca76 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/inject-discourse-objects.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/inject-discourse-objects.js @@ -5,12 +5,10 @@ import PrivateMessageTopicTrackingState from "discourse/models/private-message-t import DiscourseLocation from "discourse/lib/discourse-location"; import KeyValueStore from "discourse/lib/key-value-store"; import MessageBus from "message-bus-client"; -import ScreenTrack from "discourse/lib/screen-track"; -import SearchService from "discourse/services/search"; import Session from "discourse/models/session"; import Site from "discourse/models/site"; -import Store from "discourse/models/store"; import User from "discourse/models/user"; +import deprecated from "discourse-common/lib/deprecated"; const ALL_TARGETS = ["controller", "component", "route", "model", "adapter"]; @@ -21,9 +19,6 @@ export function registerObjects(app) { } app.__registeredObjects__ = true; - app.register("store:main", Store); - app.register("service:store", Store); - // TODO: This should be included properly app.register("message-bus:main", MessageBus, { instantiate: false }); @@ -38,6 +33,17 @@ export default { initialize(container, app) { registerObjects(app); + app.register("store:main", { + create() { + deprecated(`"store:main" is deprecated, use "service:store" instead`, { + since: "2.8.0.beta8", + dropFrom: "2.9.0.beta1", + }); + + return container.lookup("service:store"); + }, + }); + let siteSettings = container.lookup("site-settings:main"); const currentUser = User.current(); @@ -69,40 +75,43 @@ export default { const session = Session.current(); app.register("session:main", session, { instantiate: false }); - // TODO: Automatically register this service - const screenTrack = new ScreenTrack( - topicTrackingState, - siteSettings, - session, - currentUser, - container.lookup("service:app-events") - ); - app.register("service:screen-track", screenTrack, { instantiate: false }); - app.register("location:discourse-location", DiscourseLocation); const keyValueStore = new KeyValueStore("discourse_"); app.register("key-value-store:main", keyValueStore, { instantiate: false }); - app.register("search-service:main", SearchService); + + app.register("search-service:main", { + create() { + deprecated( + `"search-service:main" is deprecated, use "service:search" instead`, + { + since: "2.8.0.beta8", + dropFrom: "2.9.0.beta1", + } + ); + + return container.lookup("service:search"); + }, + }); ALL_TARGETS.forEach((t) => { app.inject(t, "appEvents", "service:app-events"); - app.inject(t, "topicTrackingState", "topic-tracking-state:main"); app.inject(t, "pmTopicTrackingState", "pm-topic-tracking-state:main"); app.inject(t, "store", "service:store"); app.inject(t, "site", "site:main"); - app.inject(t, "searchService", "search-service:main"); - app.inject(t, "keyValueStore", "key-value-store:main"); + app.inject(t, "searchService", "service:search"); }); ALL_TARGETS.concat("service").forEach((t) => { app.inject(t, "session", "session:main"); app.inject(t, "messageBus", "message-bus:main"); app.inject(t, "siteSettings", "site-settings:main"); + app.inject(t, "topicTrackingState", "topic-tracking-state:main"); + app.inject(t, "keyValueStore", "key-value-store:main"); }); if (currentUser) { - ["component", "route", "controller", "service"].forEach((t) => { + ["controller", "component", "route", "service"].forEach((t) => { app.inject(t, "currentUser", "current-user:main"); }); } diff --git a/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js b/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js index 7567fa5447..acc9e14fa5 100644 --- a/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js +++ b/app/assets/javascripts/discourse/app/pre-initializers/sniff-capabilities.js @@ -1,4 +1,9 @@ // Initializes an object that lets us know about browser's capabilities + +const APPLE_NAVIGATOR_PLATFORMS = /iPhone|iPod|iPad|Macintosh|MacIntel/; + +const APPLE_USERAGENTDATA_PLATFORM = /macOS/; + export default { name: "sniff-capabilities", @@ -28,6 +33,11 @@ export default { (/iPhone|iPod/.test(navigator.userAgent) || caps.isIpadOS) && !window.MSStream; + caps.isApple = + APPLE_NAVIGATOR_PLATFORMS.test(navigator.platform) || + (navigator.userAgentData && + APPLE_USERAGENTDATA_PLATFORM.test(navigator.userAgentData.platform)); + caps.hasContactPicker = "contacts" in navigator && "ContactsManager" in window; caps.canVibrate = "vibrate" in navigator; diff --git a/app/assets/javascripts/discourse/app/routes/about.js b/app/assets/javascripts/discourse/app/routes/about.js index efc663e0a4..44d06514af 100644 --- a/app/assets/javascripts/discourse/app/routes/about.js +++ b/app/assets/javascripts/discourse/app/routes/about.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ model() { @@ -36,10 +37,9 @@ export default DiscourseRoute.extend({ return I18n.t("about.simple_title"); }, - actions: { - didTransition() { - this.controllerFor("application").set("showFooter", true); - return true; - }, + @action + didTransition() { + this.controllerFor("application").set("showFooter", true); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js index 9ffcb3987f..dbffdf2de9 100644 --- a/app/assets/javascripts/discourse/app/routes/application.js +++ b/app/assets/javascripts/discourse/app/routes/application.js @@ -58,11 +58,6 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, { this.documentTitle.setTitle(tokens.join(" - ")); }, - // We need an empty method here for Ember to fire the action properly on all routes. - willTransition() { - this._super(...arguments); - }, - postWasEnqueued(details) { showModal("post-enqueued", { model: details, diff --git a/app/assets/javascripts/discourse/app/routes/build-admin-user-posts-route.js b/app/assets/javascripts/discourse/app/routes/build-admin-user-posts-route.js index b56971fdae..1045f31022 100644 --- a/app/assets/javascripts/discourse/app/routes/build-admin-user-posts-route.js +++ b/app/assets/javascripts/discourse/app/routes/build-admin-user-posts-route.js @@ -1,14 +1,14 @@ import DiscourseRoute from "discourse/routes/discourse"; import { emojiUnescape } from "discourse/lib/text"; import { escapeExpression } from "discourse/lib/utilities"; +import { action } from "@ember/object"; export default function (filter) { return DiscourseRoute.extend({ - actions: { - didTransition() { - this.controllerFor("user-posts")._showFooter(); - return true; - }, + @action + didTransition() { + this.controllerFor("user-posts")._showFooter(); + return true; }, model() { diff --git a/app/assets/javascripts/discourse/app/routes/discovery-categories.js b/app/assets/javascripts/discourse/app/routes/discovery-categories.js index 344a3be619..f689c27e87 100644 --- a/app/assets/javascripts/discourse/app/routes/discovery-categories.js +++ b/app/assets/javascripts/discourse/app/routes/discovery-categories.js @@ -1,6 +1,6 @@ import CategoryList from "discourse/models/category-list"; import DiscourseRoute from "discourse/routes/discourse"; -import EmberObject from "@ember/object"; +import EmberObject, { action } from "@ember/object"; import I18n from "I18n"; import OpenComposer from "discourse/mixins/open-composer"; import PreloadStore from "discourse/lib/preload-store"; @@ -130,31 +130,34 @@ const DiscoveryCategoriesRoute = DiscourseRoute.extend(OpenComposer, { }); }, - actions: { - triggerRefresh() { - this.refresh(); - }, + @action + triggerRefresh() { + this.refresh(); + }, - createCategory() { - this.transitionTo("newCategory"); - }, + @action + createCategory() { + this.transitionTo("newCategory"); + }, - reorderCategories() { - showModal("reorderCategories"); - }, + @action + reorderCategories() { + showModal("reorderCategories"); + }, - createTopic() { - if (this.get("currentUser.has_topic_draft")) { - this.openTopicDraft(); - } else { - this.openComposer(this.controllerFor("discovery/categories")); - } - }, + @action + createTopic() { + if (this.get("currentUser.has_topic_draft")) { + this.openTopicDraft(); + } else { + this.openComposer(this.controllerFor("discovery/categories")); + } + }, - didTransition() { - next(() => this.controllerFor("application").set("showFooter", true)); - return true; - }, + @action + didTransition() { + next(() => this.controllerFor("application").set("showFooter", true)); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/discovery.js b/app/assets/javascripts/discourse/app/routes/discovery.js index a0d2bcc848..55a5ca0f05 100644 --- a/app/assets/javascripts/discourse/app/routes/discovery.js +++ b/app/assets/javascripts/discourse/app/routes/discovery.js @@ -8,6 +8,7 @@ import User from "discourse/models/user"; import { scrollTop } from "discourse/mixins/scroll-top"; import { setTopicList } from "discourse/lib/topic-list-tracker"; import Site from "discourse/models/site"; +import { action } from "@ember/object"; export default DiscourseRoute.extend(OpenComposer, { queryParams: { @@ -44,53 +45,57 @@ export default DiscourseRoute.extend(OpenComposer, { } }, - actions: { - loading() { - this.controllerFor("discovery").set("loading", true); - return true; - }, + @action + loading() { + this.controllerFor("discovery").loadingBegan(); - loadingComplete() { - this.controllerFor("discovery").set("loading", false); - if (!this.session.get("topicListScrollPosition")) { - scrollTop(); - } - return false; - }, + // We don't want loading to bubble + return true; + }, - didTransition() { - this.controllerFor("discovery")._showFooter(); - this.send("loadingComplete"); + @action + loadingComplete() { + this.controllerFor("discovery").loadingComplete(); + if (!this.session.get("topicListScrollPosition")) { + scrollTop(); + } + }, - const model = this.controllerFor("discovery/topics").get("model"); - setTopicList(model); - return false; - }, + @action + didTransition() { + this.send("loadingComplete"); - // clear a pinned topic - clearPin(topic) { - topic.clearPin(); - }, + const model = this.controllerFor("discovery/topics").get("model"); + setTopicList(model); + }, - createTopic() { - if (this.get("currentUser.has_topic_draft")) { - this.openTopicDraft(); - } else { - this.openComposer(this.controllerFor("discovery/topics")); - } - }, + // clear a pinned topic + @action + clearPin(topic) { + topic.clearPin(); + }, - dismissReadTopics(dismissTopics) { - const operationType = dismissTopics ? "topics" : "posts"; - this.send("dismissRead", operationType); - }, + @action + createTopic() { + if (this.get("currentUser.has_topic_draft")) { + this.openTopicDraft(); + } else { + this.openComposer(this.controllerFor("discovery/topics")); + } + }, - dismissRead(operationType) { - const controller = this.controllerFor("discovery/topics"); - controller.send("dismissRead", operationType, { - categoryId: controller.get("category.id"), - includeSubcategories: !controller.noSubcategories, - }); - }, + @action + dismissReadTopics(dismissTopics) { + const operationType = dismissTopics ? "topics" : "posts"; + this.send("dismissRead", operationType); + }, + + @action + dismissRead(operationType) { + const controller = this.controllerFor("discovery/topics"); + controller.send("dismissRead", operationType, { + categoryId: controller.get("category.id"), + includeSubcategories: !controller.noSubcategories, + }); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/exception.js b/app/assets/javascripts/discourse/app/routes/exception.js index c87b06d4f0..2bd40f1887 100644 --- a/app/assets/javascripts/discourse/app/routes/exception.js +++ b/app/assets/javascripts/discourse/app/routes/exception.js @@ -1,14 +1,14 @@ import DiscourseRoute from "discourse/routes/discourse"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ serialize() { return ""; }, - actions: { - didTransition() { - this.controllerFor("application").set("showFooter", true); - return true; - }, + @action + didTransition() { + this.controllerFor("application").set("showFooter", true); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/full-page-search.js b/app/assets/javascripts/discourse/app/routes/full-page-search.js index 71ccb8fdbd..a348b6b504 100644 --- a/app/assets/javascripts/discourse/app/routes/full-page-search.js +++ b/app/assets/javascripts/discourse/app/routes/full-page-search.js @@ -9,6 +9,7 @@ import I18n from "I18n"; import PreloadStore from "discourse/lib/preload-store"; import { ajax } from "discourse/lib/ajax"; import { escapeExpression } from "discourse/lib/utilities"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ queryParams: { @@ -64,10 +65,9 @@ export default DiscourseRoute.extend({ }); }, - actions: { - didTransition() { - this.controllerFor("full-page-search")._showFooter(); - return true; - }, + @action + didTransition() { + this.controllerFor("full-page-search")._showFooter(); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/group-activity-posts.js b/app/assets/javascripts/discourse/app/routes/group-activity-posts.js index 0c805e51cc..dddbc26f87 100644 --- a/app/assets/javascripts/discourse/app/routes/group-activity-posts.js +++ b/app/assets/javascripts/discourse/app/routes/group-activity-posts.js @@ -1,6 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; -import { get } from "@ember/object"; +import { action, get } from "@ember/object"; export function buildGroupPage(type) { return DiscourseRoute.extend({ @@ -29,10 +29,9 @@ export function buildGroupPage(type) { this.render("group-activity-posts"); }, - actions: { - didTransition() { - return true; - }, + @action + didTransition() { + return true; }, }); } diff --git a/app/assets/javascripts/discourse/app/routes/group-index.js b/app/assets/javascripts/discourse/app/routes/group-index.js index 1a9ff92a92..c128678090 100644 --- a/app/assets/javascripts/discourse/app/routes/group-index.js +++ b/app/assets/javascripts/discourse/app/routes/group-index.js @@ -32,9 +32,7 @@ export default DiscourseRoute.extend({ showInviteModal() { const model = this.modelFor("group"); const controller = showModal("create-invite"); - controller.set("showAdvanced", true); controller.buffered.set("groupIds", [model.id]); - controller.save({ autogenerated: true }); }, @action diff --git a/app/assets/javascripts/discourse/app/routes/group-manage-logs.js b/app/assets/javascripts/discourse/app/routes/group-manage-logs.js index 4cdd9bd0cc..cf5130d466 100644 --- a/app/assets/javascripts/discourse/app/routes/group-manage-logs.js +++ b/app/assets/javascripts/discourse/app/routes/group-manage-logs.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ titleToken() { @@ -14,9 +15,8 @@ export default DiscourseRoute.extend({ this.controllerFor("group-manage-logs").setProperties({ model }); }, - actions: { - willTransition() { - this.controllerFor("group-manage-logs").reset(); - }, + @action + willTransition() { + this.controllerFor("group-manage-logs").reset(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/group-messages.js b/app/assets/javascripts/discourse/app/routes/group-messages.js index fe255814bb..bbf8cb990a 100644 --- a/app/assets/javascripts/discourse/app/routes/group-messages.js +++ b/app/assets/javascripts/discourse/app/routes/group-messages.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ titleToken() { @@ -19,9 +20,8 @@ export default DiscourseRoute.extend({ } }, - actions: { - refresh() { - this.refresh(); - }, + @action + triggerRefresh() { + this.refresh(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/preferences-account.js b/app/assets/javascripts/discourse/app/routes/preferences-account.js index 47bcae2b82..fc12eb6c07 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences-account.js +++ b/app/assets/javascripts/discourse/app/routes/preferences-account.js @@ -1,6 +1,7 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; import UserBadge from "discourse/models/user-badge"; import showModal from "discourse/lib/show-modal"; +import { action } from "@ember/object"; export default RestrictedUserRoute.extend({ showFooter: true, @@ -33,9 +34,8 @@ export default RestrictedUserRoute.extend({ }); }, - actions: { - showAvatarSelector(user) { - showModal("avatar-selector").setProperties({ user }); - }, + @action + showAvatarSelector(user) { + showModal("avatar-selector").setProperties({ user }); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/preferences-second-factor.js b/app/assets/javascripts/discourse/app/routes/preferences-second-factor.js index 54c5b9ccb3..46d296b2e5 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences-second-factor.js +++ b/app/assets/javascripts/discourse/app/routes/preferences-second-factor.js @@ -1,4 +1,5 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; +import { action } from "@ember/object"; export default RestrictedUserRoute.extend({ showFooter: true, @@ -34,27 +35,26 @@ export default RestrictedUserRoute.extend({ .finally(() => controller.set("loading", false)); }, - actions: { - willTransition(transition) { - this._super(...arguments); + @action + willTransition(transition) { + this._super(...arguments); - const controller = this.controllerFor("preferences/second-factor"); - const user = controller.get("currentUser"); - const settings = controller.get("siteSettings"); + const controller = this.controllerFor("preferences/second-factor"); + const user = controller.get("currentUser"); + const settings = controller.get("siteSettings"); - if ( - transition.targetName === "preferences.second-factor" || - !user || - user.is_anonymous || - user.second_factor_enabled || - (settings.enforce_second_factor === "staff" && !user.staff) || - settings.enforce_second_factor === "no" - ) { - return true; - } + if ( + transition.targetName === "preferences.second-factor" || + !user || + user.is_anonymous || + user.second_factor_enabled || + (settings.enforce_second_factor === "staff" && !user.staff) || + settings.enforce_second_factor === "no" + ) { + return true; + } - transition.abort(); - return false; - }, + transition.abort(); + return false; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/review-index.js b/app/assets/javascripts/discourse/app/routes/review-index.js index a19b0f9952..67c16c6a31 100644 --- a/app/assets/javascripts/discourse/app/routes/review-index.js +++ b/app/assets/javascripts/discourse/app/routes/review-index.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import { isPresent } from "@ember/utils"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ model(params) { @@ -74,9 +75,8 @@ export default DiscourseRoute.extend({ this.messageBus.unsubscribe("/reviewable_claimed"); }, - actions: { - refreshRoute() { - this.refresh(); - }, + @action + refreshRoute() { + this.refresh(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/tag-show.js b/app/assets/javascripts/discourse/app/routes/tag-show.js index bc5c59f58f..74570d4f15 100644 --- a/app/assets/javascripts/discourse/app/routes/tag-show.js +++ b/app/assets/javascripts/discourse/app/routes/tag-show.js @@ -171,10 +171,6 @@ export default DiscourseRoute.extend(FilterModeMixin, { }, actions: { - invalidateModel() { - this.refresh(); - }, - renameTag(tag) { showModal("rename-tag", { model: tag }); }, diff --git a/app/assets/javascripts/discourse/app/routes/topic-from-params.js b/app/assets/javascripts/discourse/app/routes/topic-from-params.js index e2918e1f42..b1f30baa5c 100644 --- a/app/assets/javascripts/discourse/app/routes/topic-from-params.js +++ b/app/assets/javascripts/discourse/app/routes/topic-from-params.js @@ -4,6 +4,7 @@ import Draft from "discourse/models/draft"; import { isEmpty } from "@ember/utils"; import { isTesting } from "discourse-common/config/environment"; import { schedule } from "@ember/runloop"; +import { action } from "@ember/object"; // This route is used for retrieving a topic based on params export default DiscourseRoute.extend({ @@ -114,16 +115,12 @@ export default DiscourseRoute.extend({ } }, - actions: { - willTransition() { - this.controllerFor("topic").set( - "previousURL", - document.location.pathname - ); + @action + willTransition() { + this.controllerFor("topic").set("previousURL", document.location.pathname); - // NOTE: omitting this return can break the back button when transitioning quickly between - // topics and the latest page. - return true; - }, + // NOTE: omitting this return can break the back button when transitioning quickly between + // topics and the latest page. + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/topic.js b/app/assets/javascripts/discourse/app/routes/topic.js index a5c26b1d3d..a7c4b6a3fb 100644 --- a/app/assets/javascripts/discourse/app/routes/topic.js +++ b/app/assets/javascripts/discourse/app/routes/topic.js @@ -2,7 +2,7 @@ import { cancel, later, schedule } from "@ember/runloop"; import DiscourseRoute from "discourse/routes/discourse"; import DiscourseURL from "discourse/lib/url"; import { ID_CONSTRAINT } from "discourse/models/topic"; -import { get } from "@ember/object"; +import { action, get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { inject as service } from "@ember/service"; import { setTopicId } from "discourse/lib/topic-list-tracker"; @@ -60,185 +60,197 @@ const TopicRoute = DiscourseRoute.extend({ } }, - actions: { - showInvite() { - let invitePanelTitle; + @action + showInvite() { + let invitePanelTitle; - if (this.isPM) { - invitePanelTitle = "topic.invite_private.title"; - } else if (this.invitingToTopic) { - invitePanelTitle = "topic.invite_reply.title"; - } else { - invitePanelTitle = "user.invited.create"; - } + if (this.isPM) { + invitePanelTitle = "topic.invite_private.title"; + } else if (this.invitingToTopic) { + invitePanelTitle = "topic.invite_reply.title"; + } else { + invitePanelTitle = "user.invited.create"; + } - showModal("share-and-invite", { - modalClass: "share-and-invite", - panels: [ - { - id: "invite", - title: invitePanelTitle, - model: { - inviteModel: this.modelFor("topic"), - }, + showModal("share-and-invite", { + modalClass: "share-and-invite", + panels: [ + { + id: "invite", + title: invitePanelTitle, + model: { + inviteModel: this.modelFor("topic"), }, - ], - }); - }, + }, + ], + }); + }, - showFlags(model) { - let controller = showModal("flag", { model }); - controller.setProperties({ flagTopic: false }); - }, + @action + showFlags(model) { + let controller = showModal("flag", { model }); + controller.setProperties({ flagTopic: false }); + }, - showFlagTopic() { - const model = this.modelFor("topic"); - let controller = showModal("flag", { model }); - controller.setProperties({ flagTopic: true }); - }, + @action + showFlagTopic() { + const model = this.modelFor("topic"); + let controller = showModal("flag", { model }); + controller.setProperties({ flagTopic: true }); + }, - showPagePublish() { - const model = this.modelFor("topic"); - showModal("publish-page", { - model, - title: "topic.publish_page.title", - }); - }, + @action + showPagePublish() { + const model = this.modelFor("topic"); + showModal("publish-page", { + model, + title: "topic.publish_page.title", + }); + }, - showTopicTimerModal() { - const model = this.modelFor("topic"); + @action + showTopicTimerModal() { + const model = this.modelFor("topic"); - const topicTimer = model.get("topic_timer"); - if (!topicTimer) { - model.set("topic_timer", {}); + const topicTimer = model.get("topic_timer"); + if (!topicTimer) { + model.set("topic_timer", {}); + } + + showModal("edit-topic-timer", { model }); + this.controllerFor("modal").set("modalClass", "edit-topic-timer-modal"); + }, + + @action + showTopicSlowModeUpdate() { + const model = this.modelFor("topic"); + + showModal("edit-slow-mode", { model }); + }, + + @action + showChangeTimestamp() { + showModal("change-timestamp", { + model: this.modelFor("topic"), + title: "topic.change_timestamp.title", + }); + }, + + @action + showFeatureTopic() { + showModal("featureTopic", { + model: this.modelFor("topic"), + title: "topic.feature_topic.title", + }); + this.controllerFor("modal").set("modalClass", "feature-topic-modal"); + this.controllerFor("feature_topic").reset(); + }, + + @action + showHistory(model, revision) { + let historyController = showModal("history", { + model, + modalClass: "history-modal", + }); + historyController.refresh(model.get("id"), revision || "latest"); + historyController.set("post", model); + historyController.set("topicController", this.controllerFor("topic")); + }, + + @action + showGrantBadgeModal() { + showModal("grant-badge", { + model: this.modelFor("topic"), + title: "admin.badges.grant_badge", + }); + }, + + @action + showRawEmail(model) { + showModal("raw-email", { model }); + this.controllerFor("raw_email").loadRawEmail(model.get("id")); + }, + + @action + moveToTopic() { + showModal("move-to-topic", { + model: this.modelFor("topic"), + title: "topic.move_to.title", + }); + }, + + @action + changeOwner() { + showModal("change-owner", { + model: this.modelFor("topic"), + title: "topic.change_owner.title", + }); + }, + + // Use replaceState to update the URL once it changes + @action + postChangedRoute(currentPost) { + // do nothing if we are transitioning to another route + if (this.isTransitioning || TopicRoute.disableReplaceState) { + return; + } + + const topic = this.modelFor("topic"); + if (topic && currentPost) { + let postUrl; + if (currentPost > 1) { + postUrl = topic.urlForPostNumber(currentPost); + } else { + postUrl = topic.url; } - showModal("edit-topic-timer", { model }); - this.controllerFor("modal").set("modalClass", "edit-topic-timer-modal"); - }, + if (this._router.currentRoute.queryParams) { + let searchParams; - showTopicSlowModeUpdate() { - const model = this.modelFor("topic"); - - showModal("edit-slow-mode", { model }); - }, - - showChangeTimestamp() { - showModal("change-timestamp", { - model: this.modelFor("topic"), - title: "topic.change_timestamp.title", - }); - }, - - showFeatureTopic() { - showModal("featureTopic", { - model: this.modelFor("topic"), - title: "topic.feature_topic.title", - }); - this.controllerFor("modal").set("modalClass", "feature-topic-modal"); - this.controllerFor("feature_topic").reset(); - }, - - showHistory(model, revision) { - let historyController = showModal("history", { - model, - modalClass: "history-modal", - }); - historyController.refresh(model.get("id"), revision || "latest"); - historyController.set("post", model); - historyController.set("topicController", this.controllerFor("topic")); - }, - - showGrantBadgeModal() { - showModal("grant-badge", { - model: this.modelFor("topic"), - title: "admin.badges.grant_badge", - }); - }, - - showRawEmail(model) { - showModal("raw-email", { model }); - this.controllerFor("raw_email").loadRawEmail(model.get("id")); - }, - - moveToTopic() { - showModal("move-to-topic", { - model: this.modelFor("topic"), - title: "topic.move_to.title", - }); - }, - - changeOwner() { - showModal("change-owner", { - model: this.modelFor("topic"), - title: "topic.change_owner.title", - }); - }, - - // Use replaceState to update the URL once it changes - postChangedRoute(currentPost) { - // do nothing if we are transitioning to another route - if (this.isTransitioning || TopicRoute.disableReplaceState) { - return; - } - - const topic = this.modelFor("topic"); - - if (topic && currentPost) { - let postUrl; - - if (currentPost > 1) { - postUrl = topic.urlForPostNumber(currentPost); - } else { - postUrl = topic.url; - } - - if (this._router.currentRoute.queryParams) { - let searchParams; - - Object.entries(this._router.currentRoute.queryParams).map( - ([key, value]) => { - if (!searchParams) { - searchParams = new URLSearchParams(); - } - - searchParams.append(key, value); + Object.entries(this._router.currentRoute.queryParams).map( + ([key, value]) => { + if (!searchParams) { + searchParams = new URLSearchParams(); } - ); - if (searchParams) { - postUrl += `?${searchParams.toString()}`; + searchParams.append(key, value); } + ); + + if (searchParams) { + postUrl += `?${searchParams.toString()}`; } - - cancel(this.scheduledReplace); - - this.setProperties({ - lastScrollPos: parseInt($(document).scrollTop(), 10), - scheduledReplace: later( - this, - "_replaceUnlessScrolling", - postUrl, - Ember.Test ? 0 : SCROLL_DELAY - ), - }); } - }, - didTransition() { - const controller = this.controllerFor("topic"); - controller._showFooter(); - const topicId = controller.get("model.id"); - setTopicId(topicId); - return true; - }, - - willTransition() { - this._super(...arguments); cancel(this.scheduledReplace); - this.set("isTransitioning", true); - return true; - }, + + this.setProperties({ + lastScrollPos: parseInt($(document).scrollTop(), 10), + scheduledReplace: later( + this, + "_replaceUnlessScrolling", + postUrl, + Ember.Test ? 0 : SCROLL_DELAY + ), + }); + } + }, + + @action + didTransition() { + const controller = this.controllerFor("topic"); + controller._showFooter(); + const topicId = controller.get("model.id"); + setTopicId(topicId); + return true; + }, + + @action + willTransition() { + this._super(...arguments); + cancel(this.scheduledReplace); + this.set("isTransitioning", true); + return true; }, // replaceState can be very slow on Android Chrome. This function debounces replaceState diff --git a/app/assets/javascripts/discourse/app/routes/unknown.js b/app/assets/javascripts/discourse/app/routes/unknown.js index 5f74049896..8fddb0991d 100644 --- a/app/assets/javascripts/discourse/app/routes/unknown.js +++ b/app/assets/javascripts/discourse/app/routes/unknown.js @@ -6,6 +6,10 @@ export default DiscourseRoute.extend({ model(_, transition) { const path = transition.intent.url; + if (!this.currentUser && this.siteSettings.login_required) { + return; + } + return ajax("/permalink-check.json", { data: { path }, }).then((results) => { diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js b/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js index 97e49701b4..0eaf79c8b0 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ model() { @@ -38,10 +39,9 @@ export default DiscourseRoute.extend({ this.appEvents.off("draft:destroyed", this, this.refresh); }, - actions: { - didTransition() { - this.controllerFor("user-activity")._showFooter(); - return true; - }, + @action + didTransition() { + this.controllerFor("user-activity")._showFooter(); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-pending.js b/app/assets/javascripts/discourse/app/routes/user-activity-pending.js index e5b5633164..d01768110f 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-pending.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-pending.js @@ -1,6 +1,36 @@ -import UserAction from "discourse/models/user-action"; -import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import DiscourseRoute from "discourse/routes/discourse"; -export default UserActivityStreamRoute.extend({ - userActionType: UserAction.TYPES.pending, +export default DiscourseRoute.extend({ + beforeModel() { + this.username = this.modelFor("user").username_lower; + }, + + model() { + return this.store.findAll("pending-post", { + username: this.username, + }); + }, + + activate() { + this.appEvents.on( + `count-updated:${this.username}:pending_posts_count`, + this, + "_handleCountChange" + ); + }, + + deactivate() { + this.appEvents.off( + `count-updated:${this.username}:pending_posts_count`, + this, + "_handleCountChange" + ); + }, + + _handleCountChange(count) { + this.refresh(); + if (count <= 0) { + this.transitionTo("userActivity"); + } + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-read.js b/app/assets/javascripts/discourse/app/routes/user-activity-read.js index cceb733f7d..a663745c70 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-read.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-read.js @@ -34,7 +34,7 @@ export default UserTopicListRoute.extend({ }, @action - refresh() { + triggerRefresh() { this.refresh(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-topics.js b/app/assets/javascripts/discourse/app/routes/user-activity-topics.js index c1bccb4509..9e48ca30d5 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-topics.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-topics.js @@ -30,7 +30,7 @@ export default UserTopicListRoute.extend({ }, @action - refresh() { + triggerRefresh() { this.refresh(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-badges.js b/app/assets/javascripts/discourse/app/routes/user-badges.js index 3f2d04421a..b88a8bbbaa 100644 --- a/app/assets/javascripts/discourse/app/routes/user-badges.js +++ b/app/assets/javascripts/discourse/app/routes/user-badges.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import UserBadge from "discourse/models/user-badge"; import ViewingActionType from "discourse/mixins/viewing-action-type"; +import { action } from "@ember/object"; export default DiscourseRoute.extend(ViewingActionType, { model() { @@ -19,10 +20,9 @@ export default DiscourseRoute.extend(ViewingActionType, { this.render("user/badges", { into: "user" }); }, - actions: { - didTransition() { - this.controllerFor("application").set("showFooter", true); - return true; - }, + @action + didTransition() { + this.controllerFor("application").set("showFooter", true); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-invited-show.js b/app/assets/javascripts/discourse/app/routes/user-invited-show.js index 81a4c03393..af6f4a3eeb 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited-show.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import Invite from "discourse/models/invite"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ model(params) { @@ -26,9 +27,8 @@ export default DiscourseRoute.extend({ }); }, - actions: { - triggerRefresh() { - this.refresh(); - }, + @action + triggerRefresh() { + this.refresh(); }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications.js b/app/assets/javascripts/discourse/app/routes/user-notifications.js index 600ca093bb..01ed463a75 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications.js @@ -1,5 +1,6 @@ import DiscourseRoute from "discourse/routes/discourse"; import ViewingActionType from "discourse/mixins/viewing-action-type"; +import { action } from "@ember/object"; export default DiscourseRoute.extend(ViewingActionType, { controllerName: "user-notifications", @@ -9,11 +10,10 @@ export default DiscourseRoute.extend(ViewingActionType, { this.render("user/notifications"); }, - actions: { - didTransition() { - this.controllerFor("user-notifications")._showFooter(); - return true; - }, + @action + didTransition() { + this.controllerFor("user-notifications")._showFooter(); + return true; }, model(params) { diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages.js b/app/assets/javascripts/discourse/app/routes/user-private-messages.js index 1e00226bb7..8bca93af05 100644 --- a/app/assets/javascripts/discourse/app/routes/user-private-messages.js +++ b/app/assets/javascripts/discourse/app/routes/user-private-messages.js @@ -1,6 +1,7 @@ import Composer from "discourse/models/composer"; import DiscourseRoute from "discourse/routes/discourse"; import Draft from "discourse/models/draft"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ renderTemplate() { @@ -34,15 +35,15 @@ export default DiscourseRoute.extend({ } }, - actions: { - refresh() { - this.refresh(); - }, + @action + triggerRefresh() { + this.refresh(); + }, - willTransition() { - this._super(...arguments); - this.controllerFor("user").set("pmView", null); - return true; - }, + @action + willTransition() { + this._super(...arguments); + this.controllerFor("user").set("pmView", null); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user.js b/app/assets/javascripts/discourse/app/routes/user.js index 5a3435eb72..e88075cfd6 100644 --- a/app/assets/javascripts/discourse/app/routes/user.js +++ b/app/assets/javascripts/discourse/app/routes/user.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; import User from "discourse/models/user"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ titleToken() { @@ -10,14 +11,14 @@ export default DiscourseRoute.extend({ } }, - actions: { - undoRevokeApiKey(key) { - key.undoRevoke(); - }, + @action + undoRevokeApiKey(key) { + key.undoRevoke(); + }, - revokeApiKey(key) { - key.revoke(); - }, + @action + revokeApiKey(key) { + key.revoke(); }, beforeModel() { @@ -69,6 +70,15 @@ export default DiscourseRoute.extend({ this.messageBus.subscribe(`/u/${user.username_lower}`, (data) => user.loadUserAction(data) ); + this.messageBus.subscribe(`/u/${user.username_lower}/counters`, (data) => { + user.setProperties(data); + Object.entries(data).forEach(([key, value]) => + this.appEvents.trigger( + `count-updated:${user.username_lower}:${key}`, + value + ) + ); + }); }, deactivate() { @@ -76,6 +86,7 @@ export default DiscourseRoute.extend({ const user = this.modelFor("user"); this.messageBus.unsubscribe(`/u/${user.username_lower}`); + this.messageBus.unsubscribe(`/u/${user.username_lower}/counters`); // Remove the search context this.searchService.set("searchContext", null); diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js index 5af0399c0f..442f2b3b55 100644 --- a/app/assets/javascripts/discourse/app/routes/users.js +++ b/app/assets/javascripts/discourse/app/routes/users.js @@ -3,6 +3,7 @@ import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { Promise } from "rsvp"; +import { action } from "@ember/object"; export default DiscourseRoute.extend({ queryParams: { @@ -55,10 +56,9 @@ export default DiscourseRoute.extend({ ]); }, - actions: { - didTransition() { - this.controllerFor("users")._showFooter(); - return true; - }, + @action + didTransition() { + this.controllerFor("users")._showFooter(); + return true; }, }); diff --git a/app/assets/javascripts/discourse/app/services/logs-notice.js b/app/assets/javascripts/discourse/app/services/logs-notice.js index 6c77e7c6b8..2e3143a907 100644 --- a/app/assets/javascripts/discourse/app/services/logs-notice.js +++ b/app/assets/javascripts/discourse/app/services/logs-notice.js @@ -1,22 +1,26 @@ -import discourseComputed, { - observes, - on, -} from "discourse-common/utils/decorators"; -import EmberObject from "@ember/object"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import Service from "@ember/service"; import I18n from "I18n"; import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; import getURL from "discourse-common/lib/get-url"; import { htmlSafe } from "@ember/template"; import { isEmpty } from "@ember/utils"; +import { readOnly } from "@ember/object/computed"; const LOGS_NOTICE_KEY = "logs-notice-text"; -const LogsNotice = EmberObject.extend({ +export default Service.extend({ text: "", - @on("init") - _setup() { - if (!this.isActivated) { + isAdmin: readOnly("currentUser.admin"), + + init() { + this._super(...arguments); + + if ( + this.siteSettings.alert_admins_if_errors_per_hour === 0 && + this.siteSettings.alert_admins_if_errors_per_minute === 0 + ) { return; } @@ -63,11 +67,6 @@ const LogsNotice = EmberObject.extend({ return htmlSafe(text); }, - @discourseComputed("currentUser") - isAdmin(currentUser) { - return currentUser && currentUser.admin; - }, - @discourseComputed("isEmpty", "isAdmin") hidden(thisIsEmpty, isAdmin) { return !isAdmin || thisIsEmpty; @@ -77,14 +76,4 @@ const LogsNotice = EmberObject.extend({ _updateKeyValueStore() { this.keyValueStore.setItem(LOGS_NOTICE_KEY, this.text); }, - - @discourseComputed( - "siteSettings.alert_admins_if_errors_per_hour", - "siteSettings.alert_admins_if_errors_per_minute" - ) - isActivated(errorsPerHour, errorsPerMinute) { - return errorsPerHour > 0 || errorsPerMinute > 0; - }, }); - -export default LogsNotice; diff --git a/app/assets/javascripts/discourse/app/services/presence.js b/app/assets/javascripts/discourse/app/services/presence.js index 0915ce2684..e0b8dae8e4 100644 --- a/app/assets/javascripts/discourse/app/services/presence.js +++ b/app/assets/javascripts/discourse/app/services/presence.js @@ -15,6 +15,10 @@ import Session from "discourse/models/session"; import { Promise } from "rsvp"; import { isLegacyEmber, isTesting } from "discourse-common/config/environment"; import User from "discourse/models/user"; +import userPresent, { + onPresenceChange, + removeOnPresenceChange, +} from "discourse/lib/user-presence"; const PRESENCE_INTERVAL_S = 30; const PRESENCE_DEBOUNCE_MS = isTesting() ? 0 : 500; @@ -22,6 +26,11 @@ const PRESENCE_THROTTLE_MS = isTesting() ? 0 : 1000; const PRESENCE_GET_RETRY_MS = 5000; +const USER_PRESENCE_ARGS = { + userUnseenTime: 60000, + browserHiddenTime: 10000, +}; + function createPromiseProxy() { const promiseProxy = {}; promiseProxy.promise = new Promise((resolve, reject) => { @@ -51,7 +60,11 @@ class PresenceChannel extends EmberObject { } // Mark the current user as 'present' in this channel - async enter() { + // By default, the user will temporarily 'leave' the channel when + // the current tab is in the background, or has no interaction for more than 60 seconds. + // To override this behaviour, set onlyWhileActive: false + async enter({ onlyWhileActive = true } = {}) { + this.setProperties({ onlyWhileActive }); await this.presenceService._enter(this); this.set("present", true); } @@ -221,20 +234,32 @@ class PresenceChannelState extends EmberObject { export default class PresenceService extends Service { init() { super.init(...arguments); - this._presentChannels = new Set(); this._queuedEvents = []; this._presenceChannelStates = EmberObject.create(); - this._presentProxies = {}; - this._subscribedProxies = {}; - this._initialDataRequests = {}; + this._presentProxies = new Map(); + this._subscribedProxies = new Map(); + this._initialDataRequests = new Map(); - this._beforeUnloadCallback = () => this._beaconLeaveAll(); - window.addEventListener("beforeunload", this._beforeUnloadCallback); + if (this.currentUser) { + this._beforeUnloadCallback = () => this._beaconLeaveAll(); + window.addEventListener("beforeunload", this._beforeUnloadCallback); + + this._presenceChangeCallback = () => this._throttledUpdateServer(); + onPresenceChange({ + ...USER_PRESENCE_ARGS, + callback: this._presenceChangeCallback, + }); + } + } + + get _presentChannels() { + return new Set(this._presentProxies.keys()); } willDestroy() { super.willDestroy(...arguments); window.removeEventListener("beforeunload", this._beforeUnloadCallback); + removeOnPresenceChange(this._presenceChangeCallback); } // Get a PresenceChannel object representing a single channel @@ -305,34 +330,39 @@ export default class PresenceService extends Service { } _addPresent(channelProxy) { - let present = this._presentProxies[channelProxy.name]; + let present = this._presentProxies.get(channelProxy.name); if (!present) { - present = this._presentProxies[channelProxy.name] = new Set(); + present = new Set(); + this._presentProxies.set(channelProxy.name, present); } present.add(channelProxy); return present.size; } _removePresent(channelProxy) { - let present = this._presentProxies[channelProxy.name]; + let present = this._presentProxies.get(channelProxy.name); present?.delete(channelProxy); + if (present?.size === 0) { + this._presentProxies.delete(channelProxy.name); + } return present?.size || 0; } _addSubscribed(channelProxy) { - let subscribed = this._subscribedProxies[channelProxy.name]; + let subscribed = this._subscribedProxies.get(channelProxy.name); if (!subscribed) { - subscribed = this._subscribedProxies[channelProxy.name] = new Set(); + subscribed = new Set(); + this._subscribedProxies.set(channelProxy.name, subscribed); } subscribed.add(channelProxy); return subscribed.size; } _removeSubscribed(channelProxy) { - let subscribed = this._subscribedProxies[channelProxy.name]; + let subscribed = this._subscribedProxies.get(channelProxy.name); subscribed?.delete(channelProxy); if (subscribed?.size === 0) { - delete this._subscribedProxies[channelProxy.name]; + this._subscribedProxies.delete(channelProxy.name); } return subscribed?.size || 0; } @@ -342,18 +372,15 @@ export default class PresenceService extends Service { throw "Must be logged in to enter presence channel"; } - this._addPresent(channelProxy); - - const channelName = channelProxy.name; - if (this._presentChannels.has(channelName)) { + const newCount = this._addPresent(channelProxy); + if (newCount > 1) { return; } const promiseProxy = createPromiseProxy(); - this._presentChannels.add(channelName); this._queuedEvents.push({ - channel: channelName, + channel: channelProxy.name, type: "enter", promiseProxy, }); @@ -373,16 +400,10 @@ export default class PresenceService extends Service { return; } - const channelName = channelProxy.name; - if (!this._presentChannels.has(channelName)) { - return; - } - const promiseProxy = createPromiseProxy(); - this._presentChannels.delete(channelName); this._queuedEvents.push({ - channel: channelName, + channel: channelProxy.name, type: "leave", promiseProxy, }); @@ -464,14 +485,31 @@ export default class PresenceService extends Service { this._queuedEvents = []; try { + const presentChannels = []; const channelsToLeave = queue .filter((e) => e.type === "leave") .map((e) => e.channel); + const userIsPresent = userPresent(USER_PRESENCE_ARGS); + for (const [channelName, proxies] of this._presentProxies) { + if ( + !userIsPresent && + Array.from(proxies).every((p) => p.onlyWhileActive) + ) { + channelsToLeave.push(channelName); + } else { + presentChannels.push(channelName); + } + } + + if (queue.length === 0 && presentChannels.length === 0) { + return; + } + const response = await ajax("/presence/update", { data: { client_id: this.messageBus.clientId, - present_channels: [...this._presentChannels], + present_channels: presentChannels, leave_channels: channelsToLeave, }, type: "POST", @@ -539,7 +577,7 @@ export default class PresenceService extends Service { debounce(this, this._throttledUpdateServer, PRESENCE_DEBOUNCE_MS); } else if ( !this._nextUpdateTimer && - this._presentChannels.size > 0 && + this._presentChannels.length > 0 && !isTesting() ) { this._nextUpdateTimer = later( diff --git a/app/assets/javascripts/discourse/app/lib/screen-track.js b/app/assets/javascripts/discourse/app/services/screen-track.js similarity index 94% rename from app/assets/javascripts/discourse/app/lib/screen-track.js rename to app/assets/javascripts/discourse/app/services/screen-track.js index 4ccb64d34c..647c3995f2 100644 --- a/app/assets/javascripts/discourse/app/lib/screen-track.js +++ b/app/assets/javascripts/discourse/app/services/screen-track.js @@ -1,3 +1,4 @@ +import Service, { inject as service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import { bind } from "discourse-common/utils/decorators"; import { isTesting } from "discourse-common/config/environment"; @@ -10,21 +11,24 @@ const ANON_MAX_TOPIC_IDS = 5; const AJAX_FAILURE_DELAYS = [5000, 10000, 20000, 40000]; const ALLOWED_AJAX_FAILURES = [405, 429, 500, 501, 502, 503, 504]; -export default class { - constructor( - topicTrackingState, - siteSettings, - session, - currentUser, - appEvents - ) { - this.topicTrackingState = topicTrackingState; - this.siteSettings = siteSettings; - this.session = session; - this.currentUser = currentUser; - this.appEvents = appEvents; +export default class ScreenTrack extends Service { + @service appEvents; + + _consolidatedTimings = []; + _lastTick = null; + _lastScrolled = null; + _lastFlush = 0; + _timings = {}; + _totalTimings = {}; + _topicTime = 0; + _onscreen = []; + _readOnscreen = []; + _readPosts = {}; + _inProgress = false; + + constructor() { + super(...arguments); this.reset(); - this._consolidatedTimings = []; } start(topicId, topicController) { @@ -237,7 +241,8 @@ export default class { ] = 1; } - Object.keys(newTimings).forEach((postNumber) => { + const newTimingsKeys = Object.keys(newTimings); + newTimingsKeys.forEach((postNumber) => { highestSeen = Math.max(highestSeen, parseInt(postNumber, 10)); }); @@ -248,7 +253,7 @@ export default class { this.topicTrackingState.updateSeen(topicId, highestSeen); - if (!$.isEmptyObject(newTimings)) { + if (newTimingsKeys.length > 0) { if (this.currentUser && !isTesting()) { this.consolidateTimings(newTimings, this._topicTime, topicId); this.sendNextConsolidatedTiming(); @@ -267,6 +272,7 @@ export default class { } else { topicIds = []; } + if ( topicIds.indexOf(topicId) === -1 && topicIds.length < ANON_MAX_TOPIC_IDS diff --git a/app/assets/javascripts/discourse/app/services/search.js b/app/assets/javascripts/discourse/app/services/search.js index a702e9980b..2fe2dacaec 100644 --- a/app/assets/javascripts/discourse/app/services/search.js +++ b/app/assets/javascripts/discourse/app/services/search.js @@ -1,7 +1,7 @@ -import EmberObject, { get } from "@ember/object"; +import Service from "@ember/service"; import discourseComputed from "discourse-common/utils/decorators"; -export default EmberObject.extend({ +export default Service.extend({ searchContextEnabled: false, // checkbox to scope search searchContext: null, highlightTerm: null, @@ -9,16 +9,13 @@ export default EmberObject.extend({ @discourseComputed("searchContext") contextType: { get(searchContext) { - if (searchContext) { - return get(searchContext, "type"); - } + return searchContext?.type; }, + set(value, searchContext) { - // a bit hacky, consider cleaning this up, need to work through all observers though - const context = Object.assign({}, searchContext); - context.type = value; - this.set("searchContext", context); - return this.get("searchContext.type"); + this.set("searchContext", { ...searchContext, type: value }); + + return value; }, }, }); diff --git a/app/assets/javascripts/discourse/app/services/store.js b/app/assets/javascripts/discourse/app/services/store.js new file mode 100644 index 0000000000..4eaf4b5211 --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/store.js @@ -0,0 +1,384 @@ +import Service from "@ember/service"; +import { set } from "@ember/object"; +import { Promise } from "rsvp"; +import RestModel from "discourse/models/rest"; +import ResultSet from "discourse/models/result-set"; +import { ajax } from "discourse/lib/ajax"; +import { getRegister } from "discourse-common/lib/get-owner"; +import { underscore } from "@ember/string"; + +let _identityMap; + +// You should only call this if you're a test scaffold +function flushMap() { + _identityMap = {}; +} + +function storeMap(type, id, obj) { + if (!id) { + return; + } + + _identityMap[type] = _identityMap[type] || {}; + _identityMap[type][id] = obj; +} + +function fromMap(type, id) { + const byType = _identityMap[type]; + if (byType && byType.hasOwnProperty(id)) { + return byType[id]; + } +} + +function removeMap(type, id) { + const byType = _identityMap[type]; + if (byType && byType.hasOwnProperty(id)) { + delete byType[id]; + } +} + +function findAndRemoveMap(type, id) { + const byType = _identityMap[type]; + if (byType && byType.hasOwnProperty(id)) { + const result = byType[id]; + delete byType[id]; + return result; + } +} + +flushMap(); + +export default Service.extend({ + _plurals: { + category: "categories", + "post-reply": "post-replies", + "post-reply-history": "post_reply_histories", + reviewable_history: "reviewable_histories", + }, + + init() { + this._super(...arguments); + this.register = this.register || getRegister(this); + }, + + pluralize(thing) { + return this._plurals[thing] || thing + "s"; + }, + + addPluralization(thing, plural) { + this._plurals[thing] = plural; + }, + + findAll(type, findArgs) { + const adapter = this.adapterFor(type); + + let store = this; + return adapter.findAll(this, type, findArgs).then((result) => { + let results = this._resultSet(type, result); + if (adapter.afterFindAll) { + results = adapter.afterFindAll(results, { + lookup(subType, id) { + return store._lookupSubType(subType, type, id, result); + }, + }); + } + return results; + }); + }, + + // Mostly for legacy, things like TopicList without ResultSets + findFiltered(type, findArgs) { + return this.adapterFor(type) + .find(this, type, findArgs) + .then((result) => this._build(type, result)); + }, + + _hydrateFindResults(result, type, findArgs) { + if (typeof findArgs === "object") { + return this._resultSet(type, result, findArgs); + } else { + const apiName = this.adapterFor(type).apiNameFor(type); + return this._hydrate(type, result[underscore(apiName)], result); + } + }, + + // See if the store can find stale data. We sometimes prefer to show stale data and + // refresh it in the background. + findStale(type, findArgs, opts) { + const stale = this.adapterFor(type).findStale(this, type, findArgs, opts); + return { + hasResults: stale !== undefined, + results: stale, + refresh: () => this.find(type, findArgs, opts), + }; + }, + + find(type, findArgs, opts) { + let adapter = this.adapterFor(type); + return adapter.find(this, type, findArgs, opts).then((result) => { + let hydrated = this._hydrateFindResults(result, type, findArgs, opts); + + if (result.extras) { + hydrated.set("extras", result.extras); + } + + if (adapter.cache) { + const stale = adapter.findStale(this, type, findArgs, opts); + hydrated = this._updateStale(stale, hydrated, adapter.primaryKey); + adapter.cacheFind(this, type, findArgs, opts, hydrated); + } + return hydrated; + }); + }, + + _updateStale(stale, hydrated, primaryKey) { + if (!stale) { + return hydrated; + } + + hydrated.set( + "content", + hydrated.get("content").map((item) => { + let staleItem = stale.content.findBy(primaryKey, item.get(primaryKey)); + if (staleItem) { + staleItem.setProperties(item); + } else { + staleItem = item; + } + return staleItem; + }) + ); + return hydrated; + }, + + refreshResults(resultSet, type, url) { + const adapter = this.adapterFor(type); + return ajax(url).then((result) => { + const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); + const content = result[typeName].map((obj) => + this._hydrate(type, obj, result) + ); + resultSet.set("content", content); + }); + }, + + appendResults(resultSet, type, url) { + const adapter = this.adapterFor(type); + return ajax(url).then((result) => { + const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); + + let pageTarget = result.meta || result; + let totalRows = + pageTarget["total_rows_" + typeName] || resultSet.get("totalRows"); + let loadMoreUrl = pageTarget["load_more_" + typeName]; + let content = result[typeName].map((obj) => + this._hydrate(type, obj, result) + ); + + resultSet.setProperties({ totalRows, loadMoreUrl }); + resultSet.get("content").pushObjects(content); + + // If we've loaded them all, clear the load more URL + if (resultSet.get("length") >= totalRows) { + resultSet.set("loadMoreUrl", null); + } + }); + }, + + update(type, id, attrs) { + const adapter = this.adapterFor(type); + return adapter.update(this, type, id, attrs, function (result) { + if (result && result[type] && result[type][adapter.primaryKey]) { + const oldRecord = findAndRemoveMap(type, id); + storeMap(type, result[type][adapter.primaryKey], oldRecord); + } + return result; + }); + }, + + createRecord(type, attrs) { + attrs = attrs || {}; + const adapter = this.adapterFor(type); + return !!attrs[adapter.primaryKey] + ? this._hydrate(type, attrs) + : this._build(type, attrs); + }, + + destroyRecord(type, record) { + const adapter = this.adapterFor(type); + + // If the record is new, don't perform an Ajax call + if (record.get("isNew")) { + removeMap(type, record.get(adapter.primaryKey)); + return Promise.resolve(true); + } + + return adapter.destroyRecord(this, type, record).then(function (result) { + removeMap(type, record.get(adapter.primaryKey)); + return result; + }); + }, + + _resultSet(type, result, findArgs) { + const adapter = this.adapterFor(type); + const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); + + if (!result[typeName]) { + // eslint-disable-next-line no-console + console.error(`JSON response is missing \`${typeName}\` key`, result); + return; + } + + const content = result[typeName].map((obj) => + this._hydrate(type, obj, result) + ); + + let pageTarget = result.meta || result; + + const createArgs = { + content, + findArgs, + totalRows: pageTarget["total_rows_" + typeName] || content.length, + loadMoreUrl: pageTarget["load_more_" + typeName], + refreshUrl: pageTarget["refresh_" + typeName], + resultSetMeta: result.meta, + store: this, + __type: type, + }; + + if (result.extras) { + createArgs.extras = result.extras; + } + + return ResultSet.create(createArgs); + }, + + _build(type, obj) { + const adapter = this.adapterFor(type); + obj.store = this; + obj.__type = type; + obj.__state = obj[adapter.primaryKey] ? "created" : "new"; + + // TODO: Have injections be automatic + obj.topicTrackingState = this.register.lookup("topic-tracking-state:main"); + obj.keyValueStore = this.register.lookup("key-value-store:main"); + + const klass = this.register.lookupFactory("model:" + type) || RestModel; + const model = klass.create(obj); + + storeMap(type, obj[adapter.primaryKey], model); + return model; + }, + + adapterFor(type) { + return ( + this.register.lookup("adapter:" + type) || + this.register.lookup("adapter:rest") + ); + }, + + _lookupSubType(subType, type, id, root) { + if (root.meta && root.meta.types) { + subType = root.meta.types[subType] || subType; + } + + const subTypeAdapter = this.adapterFor(subType); + const pluralType = this.pluralize(subType); + const collection = root[this.pluralize(subType)]; + if (collection) { + const hashedProp = "__hashed_" + pluralType; + let hashedCollection = root[hashedProp]; + if (!hashedCollection) { + hashedCollection = {}; + collection.forEach(function (it) { + hashedCollection[it[subTypeAdapter.primaryKey]] = it; + }); + root[hashedProp] = hashedCollection; + } + + const found = hashedCollection[id]; + if (found) { + const hydrated = this._hydrate(subType, found, root); + hashedCollection[id] = hydrated; + return hydrated; + } + } + }, + + _hydrateEmbedded(type, obj, root) { + const adapter = this.adapterFor(type); + Object.keys(obj).forEach((k) => { + if (k === adapter.primaryKey) { + return; + } + + const m = /(.+)\_id(s?)$/.exec(k); + if (m) { + const subType = m[1]; + + if (m[2]) { + const hydrated = obj[k].map((id) => + this._lookupSubType(subType, type, id, root) + ); + obj[this.pluralize(subType)] = hydrated || []; + delete obj[k]; + } else { + const hydrated = this._lookupSubType(subType, type, obj[k], root); + if (hydrated) { + obj[subType] = hydrated; + delete obj[k]; + } else { + set(obj, subType, null); + } + } + } + }); + }, + + _hydrate(type, obj, root) { + if (!obj) { + throw new Error("Can't hydrate " + type + " of `null`"); + } + + const adapter = this.adapterFor(type); + + const id = obj[adapter.primaryKey]; + if (!id) { + throw new Error( + `Can't hydrate ${type} without primaryKey: \`${adapter.primaryKey}\`` + ); + } + + root = root || obj; + + if (root.__rest_serializer === "1") { + this._hydrateEmbedded(type, obj, root); + } + + const existing = fromMap(type, id); + if (existing === obj) { + return existing; + } + + if (existing) { + delete obj[adapter.primaryKey]; + let klass = this.register.lookupFactory("model:" + type); + + if (klass && klass.class) { + klass = klass.class; + } + + if (!klass) { + klass = RestModel; + } + + existing.setProperties(klass.munge(obj)); + obj[adapter.primaryKey] = id; + return existing; + } + + return this._build(type, obj); + }, +}); + +export { flushMap }; diff --git a/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs b/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs index 9dbfd96795..a5e6d62b17 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/bookmark-list.hbs @@ -1,19 +1,19 @@ {{#conditional-loading-spinner condition=loading}} {{#load-more selector=".bookmark-list .bookmark-list-item" action=loadMore}} - + {{#unless site.mobileView}} - - - - + + + + {{/unless}} - + {{#each content as |bookmark|}} - {{#unless site.mobileView}} - - + {{raw "list/activity-column" topic=bookmark class="num post-metadata" tagName="td"}} {{/unless}} - + {{raw "topic-list-header" canBulkSelect=canBulkSelect toggleInTitle=toggleInTitle @@ -29,7 +29,7 @@ tagName="" connectorTagName=""}} - + {{#each filteredTopics as |topic|}} {{topic-list-item topic=topic bulkSelectEnabled=bulkSelectEnabled diff --git a/app/assets/javascripts/discourse/app/templates/composer.hbs b/app/assets/javascripts/discourse/app/templates/composer.hbs index 4d6a4c32f3..e1c50bc71b 100644 --- a/app/assets/javascripts/discourse/app/templates/composer.hbs +++ b/app/assets/javascripts/discourse/app/templates/composer.hbs @@ -111,7 +111,7 @@ - {{component composerComponent + {{composer-editor topic=topic composer=model lastValidatedAt=lastValidatedAt @@ -129,6 +129,7 @@ uploadProgress=uploadProgress groupsMentioned=(action "groupsMentioned") cannotSeeMention=(action "cannotSeeMention") + hereMention=(action "hereMention") importQuote=(action "importQuote") togglePreview=(action "togglePreview") processPreview=showPreview diff --git a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs index 4c9eee8c2a..795d559a1a 100644 --- a/app/assets/javascripts/discourse/app/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/app/templates/full-page-search.hbs @@ -24,8 +24,10 @@ id="search-type" value=search_type content=searchTypes - castInteger=true onChange=(action (mut search_type)) + options=(hash + castInteger=true + ) }} {{d-button action=(action "search" (hash collapseFilters=true)) @@ -90,9 +92,11 @@ {{combo-box value=sortOrder content=sortOrders - castInteger=true onChange=(action (mut sortOrder)) id="search-sort-by" + options=(hash + castInteger=true + ) }} diff --git a/app/assets/javascripts/discourse/app/templates/list/category-column.hbr b/app/assets/javascripts/discourse/app/templates/list/category-column.hbr index 10c175aac6..32cc25b0c2 100644 --- a/app/assets/javascripts/discourse/app/templates/list/category-column.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/category-column.hbr @@ -1 +1 @@ - + diff --git a/app/assets/javascripts/discourse/app/templates/list/posters-column.hbr b/app/assets/javascripts/discourse/app/templates/list/posters-column.hbr index 38d50d468b..72aee8790d 100644 --- a/app/assets/javascripts/discourse/app/templates/list/posters-column.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/posters-column.hbr @@ -1,4 +1,4 @@ - {{/if}} - + -{{raw "list/activity-column" topic=topic class="num" tagName="td"}} +{{raw "list/activity-column" topic=topic class="num topic-list-data" tagName="td"}} diff --git a/app/assets/javascripts/discourse/app/templates/list/visited-line.hbr b/app/assets/javascripts/discourse/app/templates/list/visited-line.hbr index 16558146c5..31b08654dc 100644 --- a/app/assets/javascripts/discourse/app/templates/list/visited-line.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/visited-line.hbr @@ -1,6 +1,6 @@ {{#if view.isLastVisited}} -
{{i18n "topic.title"}} {{i18n "topic.title"}}   
+ {{#if bookmark.post_user_avatar_template}} {{avatar bookmark.postUser avatarTemplatePath="avatar_template" usernamePath="username" namePath="name" imageSize="small"}} {{/if}} + {{bookmark-actions-dropdown bookmark=bookmark removeBookmark=(action "removeBookmark") diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs b/app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs index 29c22f16fa..4b6ffd510d 100644 --- a/app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/categories-and-latest-topics.hbs @@ -5,3 +5,5 @@
{{categories-topic-list topics=topics filter="latest" class="latest-topic-list"}}
+ +{{plugin-outlet name="extra-categories-column" tagName=""}} diff --git a/app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs b/app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs index ebb4a35781..66cd56ff2e 100644 --- a/app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/categories-and-top-topics.hbs @@ -5,3 +5,5 @@
{{categories-topic-list topics=topics filter="top" class="top-topic-list"}}
+ +{{plugin-outlet name="extra-categories-column" tagName=""}} diff --git a/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs b/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs index acc33b06b8..b6119e5869 100644 --- a/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs @@ -1,5 +1,6 @@ {{yield (hash data=data uploading=uploading - progress=progress + uploadProgress=uploadProgress uploaded=uploaded - submitDisabled=submitDisabled)}} + submitDisabled=submitDisabled + startUpload=(action "startUpload"))}} diff --git a/app/assets/javascripts/discourse/app/templates/components/empty-state.hbs b/app/assets/javascripts/discourse/app/templates/components/empty-state.hbs new file mode 100644 index 0000000000..e903c42cfc --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/empty-state.hbs @@ -0,0 +1,6 @@ +
+ {{@title}} +
+

{{@body}}

+
+
diff --git a/app/assets/javascripts/discourse/app/templates/components/groups-form-interaction-fields.hbs b/app/assets/javascripts/discourse/app/templates/components/groups-form-interaction-fields.hbs index 7681b30a85..ad186711ea 100644 --- a/app/assets/javascripts/discourse/app/templates/components/groups-form-interaction-fields.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/groups-form-interaction-fields.hbs @@ -8,9 +8,11 @@ valueProperty="value" value=model.visibility_level content=visibilityLevelOptions - castInteger=true class="groups-form-visibility-level" onChange=(action (mut model.visibility_level)) + options=(hash + castInteger=true + ) }}
diff --git a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs index cba646b2be..f334a04109 100644 --- a/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/login-buttons.hbs @@ -1,7 +1,7 @@ {{#each buttons as |b|}}
{{category-link category}}{{category-link category}} + {{#each posters as |poster|}} {{#if poster.moreCount}} {{poster.moreCount}} diff --git a/app/assets/javascripts/discourse/app/templates/list/posts-count-column.hbr b/app/assets/javascripts/discourse/app/templates/list/posts-count-column.hbr index 4c847ddb11..a697ff9e01 100644 --- a/app/assets/javascripts/discourse/app/templates/list/posts-count-column.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/posts-count-column.hbr @@ -1,4 +1,4 @@ -<{{view.tagName}} class='num posts-map posts {{view.likesHeat}}' title='{{view.title}}'> +<{{view.tagName}} class='num posts-map posts {{view.likesHeat}} topic-list-data' title='{{view.title}}'> + @@ -15,21 +15,21 @@ 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. --}} -{{number topic.views numberKey="views_long"}} + {{raw-plugin-outlet name="topic-list-before-view-count"}} + {{number topic.views numberKey="views_long"}} +
+ {{i18n 'topics.new_messages_marker'}} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/list/topic-list-item.hbr b/app/assets/javascripts/discourse/app/templates/mobile/list/topic-list-item.hbr index ae3b4bf252..b53847c406 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/list/topic-list-item.hbr +++ b/app/assets/javascripts/discourse/app/templates/mobile/list/topic-list-item.hbr @@ -1,4 +1,4 @@ - + {{~raw-plugin-outlet name="topic-list-before-columns"}} {{~#if showMobileAvatar}}
diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs index 3973a3912e..de9fae0437 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs @@ -13,10 +13,10 @@ {{#unless status.uploaded}} {{d-button icon=(if isEmail "envelope" "link") - translatedLabel=(if status.uploading (i18n "user.invited.bulk_invite.progress" progress=status.progress) + translatedLabel=(if status.uploading (i18n "user.invited.bulk_invite.progress" progress=status.uploadProgress) (i18n "user.invited.bulk_invite.text")) class="btn-primary" - action=(action "submit" status.data) + action=status.startUpload disabled=status.submitDisabled }} {{/unless}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs index 6754afe8d7..d31844c484 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs @@ -1,21 +1,40 @@ -{{#d-modal-body title=(if invite.id "user.invited.invite.edit_title" "user.invited.invite.new_title")}} -
-
diff --git a/app/assets/javascripts/discourse/app/templates/modal/dismiss-notification-confirmation.hbs b/app/assets/javascripts/discourse/app/templates/modal/dismiss-notification-confirmation.hbs new file mode 100644 index 0000000000..ea948b48bc --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/dismiss-notification-confirmation.hbs @@ -0,0 +1,15 @@ +{{#d-modal-body headerClass="hidden" class="dismiss-notification-confirmation"}} + {{i18n "notifications.dismiss_confirmation.body" count=count}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/app/templates/modal/history.hbs b/app/assets/javascripts/discourse/app/templates/modal/history.hbs index 38d5bed5c2..d47e76e5f6 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/history.hbs @@ -84,12 +84,12 @@
{{i18n "tagging.changed"}} {{#each previousTagChanges as |t|}} - {{discourse-tag t}} + {{discourse-tag t.name style=(if t.deleted "diff-del")}} {{/each}} →   {{#each currentTagChanges as |t|}} - {{discourse-tag t}} + {{discourse-tag t.name style=(if t.inserted "diff-ins")}} {{/each}}
{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs b/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs index 60edf99f9f..9488750a37 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs @@ -100,6 +100,7 @@
  • {{html-safe shortcuts.search_menu.prev_next}}
  • {{html-safe shortcuts.search_menu.insert_url}}
  • +
  • {{html-safe shortcuts.search_menu.full_page_search}}
diff --git a/app/assets/javascripts/discourse/app/templates/modal/rename-tag.hbs b/app/assets/javascripts/discourse/app/templates/modal/rename-tag.hbs deleted file mode 100644 index 0ce71cba7d..0000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/rename-tag.hbs +++ /dev/null @@ -1,21 +0,0 @@ -{{#d-modal-body title="tagging.rename_tag"}} - -
- {{input - value=(readonly model.id) - maxlength=siteSettings.max_tag_length - input=(action (mut newTag) value="target.value") - }} -
-{{/d-modal-body}} - - diff --git a/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs b/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs index cbdac5666a..330637850b 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs @@ -12,6 +12,7 @@ {{copy-button selector="input.invite-link"}} +
+ {{~#if canBulkSelect}} {{~#if showBulkToggle}} {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}} diff --git a/app/assets/javascripts/discourse/app/templates/topic-list-header.hbr b/app/assets/javascripts/discourse/app/templates/topic-list-header.hbr index 0d47362b71..636225a3b1 100644 --- a/app/assets/javascripts/discourse/app/templates/topic-list-header.hbr +++ b/app/assets/javascripts/discourse/app/templates/topic-list-header.hbr @@ -1,6 +1,6 @@ {{~raw-plugin-outlet name="topic-list-header-before"~}} {{#if bulkSelectEnabled}} - + {{#if canBulkSelect}} {{raw "flat-button" class="bulk-select" icon="list" title="topics.bulk.toggle"}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/user-activity-pending.hbs b/app/assets/javascripts/discourse/app/templates/user-activity-pending.hbs new file mode 100644 index 0000000000..2b39f3fa6c --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/user-activity-pending.hbs @@ -0,0 +1,5 @@ +
    + {{#each this.model as |pending_post|}} + + {{/each}} +
diff --git a/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs b/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs index afe4706efd..21f989da46 100644 --- a/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs +++ b/app/assets/javascripts/discourse/app/templates/user-topics-list.hbs @@ -1,12 +1,10 @@ {{#if noContent}} -
- {{model.emptyState.title}} -
-

{{model.emptyState.body}}

-
-
+ {{else}} - {{#load-more class="paginated-topics-list" selector=".paginated-topics-list .topic-list tr" action=(action "loadMore")}} + {{#load-more class="paginated-topics-list" selector=".paginated-topics-list .topic-list .topic-list-item" action=(action "loadMore")}} {{topic-dismiss-buttons position="top" selectedTopics=selected @@ -27,7 +25,7 @@ hideCategory=hideCategory showPosters=showPosters bulkSelectEnabled=bulkSelectEnabled - bulkSelectAction=(route-action "refresh") + bulkSelectAction=(action "refresh") selected=selected tagsForUser=tagsForUser onScroll=saveScrollPosition diff --git a/app/assets/javascripts/discourse/app/templates/user/activity.hbs b/app/assets/javascripts/discourse/app/templates/user/activity.hbs index a7fe65f355..260e40241c 100644 --- a/app/assets/javascripts/discourse/app/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/activity.hbs @@ -17,6 +17,12 @@ {{/d-navigation-item}} {{/if}} + {{#if (gt model.pending_posts_count 0)}} + {{#d-navigation-item route="userActivity.pending"}} + {{pendingLabel}} + {{/d-navigation-item}} + {{/if}} + {{#d-navigation-item route="userActivity.likesGiven"}}{{i18n "user_action_groups.1"}}{{/d-navigation-item}} {{#if user.showBookmarks}} diff --git a/app/assets/javascripts/discourse/app/templates/user/bookmarks.hbs b/app/assets/javascripts/discourse/app/templates/user/bookmarks.hbs index a384c8adca..105ce3924d 100644 --- a/app/assets/javascripts/discourse/app/templates/user/bookmarks.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/bookmarks.hbs @@ -2,12 +2,10 @@ {{#if permissionDenied}}
{{i18n "bookmarks.list_permission_denied"}}
{{else if userDoesNotHaveBookmarks}} -
- {{i18n "user.no_bookmarks_title"}} -
-

{{emptyStateBody}}

-
-
+ {{else}}
{{input type="text" diff --git a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs index b138dfbe53..d1a35c252f 100644 --- a/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/notifications-index.hbs @@ -7,12 +7,10 @@ {{/if}}
{{else if doesNotHaveNotifications}} -
- {{i18n "user.no_notifications_page_title"}} -
-

{{emptyStateBody}}

-
-
+ {{else}}
{{notifications-filter value=filter onChange=(action (mut filter))}} diff --git a/app/assets/javascripts/discourse/app/templates/user/stream.hbs b/app/assets/javascripts/discourse/app/templates/user/stream.hbs index baa63e4cbd..32c237787d 100644 --- a/app/assets/javascripts/discourse/app/templates/user/stream.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/stream.hbs @@ -2,12 +2,10 @@ {{#if model.isAnotherUsersPage}}
{{model.emptyStateOthers}}
{{else}} -
- {{model.emptyState.title}} -
-

{{model.emptyState.body}}

-
-
+ {{/if}} {{/if}} {{user-stream stream=model.stream}} diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 9cfd4091f6..377e76304d 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -328,7 +328,7 @@ export function attachAdditionalPanel(name, toggle, transformAttrs) { export default createWidget("header", { tagName: "header.d-header.clearfix", buildKey: () => `header`, - services: ["router"], + services: ["router", "search"], defaultState() { let states = { @@ -408,8 +408,7 @@ export default createWidget("header", { updateHighlight() { if (!this.state.searchVisible) { - const service = this.register.lookup("search-service:main"); - service.set("highlightTerm", ""); + this.search.set("highlightTerm", ""); } }, @@ -447,8 +446,7 @@ export default createWidget("header", { toggleSearchMenu() { if (this.site.mobileView) { - const searchService = this.register.lookup("search-service:main"); - const context = searchService.get("searchContext"); + const context = this.search.searchContext; let params = ""; if (context) { diff --git a/app/assets/javascripts/discourse/app/widgets/hooks.js b/app/assets/javascripts/discourse/app/widgets/hooks.js index fe6457e4f8..17c34d1519 100644 --- a/app/assets/javascripts/discourse/app/widgets/hooks.js +++ b/app/assets/javascripts/discourse/app/widgets/hooks.js @@ -18,12 +18,14 @@ const MOUSE_OUT_ATTRIBUTE_NAME = "_discourse_mouse_out_widget"; const TOUCH_START_ATTRIBUTE_NAME = "_discourse_touch_start_widget"; const TOUCH_END_ATTRIBUTE_NAME = "_discourse_touch_end_widget"; -function buildHook(attributeName, setAttr) { - return class { - constructor(widget) { - this.widget = widget; - } +class WidgetBaseHook { + constructor(widget) { + this.widget = widget; + } +} +function buildHook(attributeName, setAttr) { + return class extends WidgetBaseHook { hook(node) { if (setAttr) { node.setAttribute(setAttr, true); @@ -40,6 +42,8 @@ function buildHook(attributeName, setAttr) { }; } +// For the majority of events, we register a single listener on the ``, and then +// notify the relavent widget (if any) when the event fires (see setupDocumentCallback() below) export const WidgetClickHook = buildHook(CLICK_ATTRIBUTE_NAME); export const WidgetDoubleClickHook = buildHook(DOUBLE_CLICK_ATTRIBUTE_NAME); export const WidgetClickOutsideHook = buildHook( @@ -52,7 +56,6 @@ export const WidgetMouseDownOutsideHook = buildHook( ); export const WidgetKeyUpHook = buildHook(KEY_UP_ATTRIBUTE_NAME); export const WidgetKeyDownHook = buildHook(KEY_DOWN_ATTRIBUTE_NAME); -export const WidgetDragHook = buildHook(DRAG_ATTRIBUTE_NAME); export const WidgetInputHook = buildHook(INPUT_ATTRIBUTE_NAME); export const WidgetChangeHook = buildHook(CHANGE_ATTRIBUTE_NAME); export const WidgetMouseUpHook = buildHook(MOUSE_UP_ATTRIBUTE_NAME); @@ -60,9 +63,97 @@ export const WidgetMouseDownHook = buildHook(MOUSE_DOWN_ATTRIBUTE_NAME); export const WidgetMouseMoveHook = buildHook(MOUSE_MOVE_ATTRIBUTE_NAME); export const WidgetMouseOverHook = buildHook(MOUSE_OVER_ATTRIBUTE_NAME); export const WidgetMouseOutHook = buildHook(MOUSE_OUT_ATTRIBUTE_NAME); -export const WidgetTouchStartHook = buildHook(TOUCH_START_ATTRIBUTE_NAME); export const WidgetTouchEndHook = buildHook(TOUCH_END_ATTRIBUTE_NAME); +// `touchstart` and `touchmove` events are particularly performance sensitive because +// they block scrolling on mobile. Therefore we want to avoid registering global non-passive +// listeners for these events. +// Instead, the WidgetTouchStartHook and WidgetDragHook automatically register listeners on +// the specific widget DOM elements when required. +function touchStartHandler(e) { + return e.currentTarget[TOUCH_START_ATTRIBUTE_NAME].touchStart(e); +} + +export class WidgetTouchStartHook extends WidgetBaseHook { + hook(node, propertyName, previousValue) { + node[TOUCH_START_ATTRIBUTE_NAME] = this.widget; + if (!previousValue) { + // Element added to DOM + node.addEventListener("touchstart", touchStartHandler, { + passive: false, + }); + } + } + + unhook(node, propertyName, newValue) { + if (!newValue) { + // Element removed from DOM + node.removeEventListener("touchstart", touchStartHandler); + } + } +} + +let _currentlyDraggingElement; + +function dragStart(e) { + e.preventDefault(); + e.stopPropagation(); + if (_currentlyDraggingElement) { + dragEnd(); + } + _currentlyDraggingElement = e.currentTarget; + document.body.classList.add("widget-dragging"); + document.addEventListener("touchmove", drag, { passive: false }); + document.addEventListener("mousemove", drag, { passive: false }); + document.addEventListener("touchend", dragEnd); + document.addEventListener("mouseup", dragEnd); +} + +function drag(e) { + const widget = _currentlyDraggingElement[DRAG_ATTRIBUTE_NAME]; + if (event.type === "mousemove") { + widget.drag(e); + } else { + const tt = e.targetTouches[0]; + e.preventDefault(); + e.stopPropagation(); + widget.drag(tt); + } +} + +function dragEnd(e) { + document.body.classList.remove("widget-dragging"); + document.removeEventListener("touchmove", drag); + document.removeEventListener("mousemove", drag); + document.removeEventListener("touchend", dragEnd); + document.removeEventListener("mouseup", dragEnd); + const widget = _currentlyDraggingElement[DRAG_ATTRIBUTE_NAME]; + widget.dragEnd(e); + _currentlyDraggingElement = null; +} + +export class WidgetDragHook extends WidgetBaseHook { + hook(node, propertyName, previousValue) { + node[DRAG_ATTRIBUTE_NAME] = this.widget; + if (!previousValue) { + // Adding to DOM + node.addEventListener("touchstart", dragStart, { passive: false }); + node.addEventListener("mousedown", dragStart, { passive: false }); + } + } + + unhook(node, propertyName, newValue) { + if (!newValue) { + // Removing from DOM + if (_currentlyDraggingElement === node) { + dragEnd(); + } + node.removeEventListener("touchstart", dragStart); + node.removeEventListener("mousedown", dragStart); + } + } +} + function nodeCallback(node, attrName, cb, options = { rerender: true }) { const { rerender } = options; const widget = findWidget(node, attrName); @@ -87,39 +178,12 @@ function findWidget(node, attrName) { } let _watchingDocument = false; -let _dragging; - -const DRAG_NAME = "mousemove.discourse-widget-drag"; - -function cancelDrag(e) { - $("body").removeClass("widget-dragging"); - $(document).off(DRAG_NAME); - - // We leave the touchmove event cause touch needs it always bound on iOS - - if (_dragging) { - if (_dragging.dragEnd) { - _dragging.dragEnd(e); - } - _dragging = null; - } -} WidgetClickHook.setupDocumentCallback = function () { if (_watchingDocument) { return; } - let widget; - let onDrag = (dragE) => { - const tt = dragE.targetTouches[0]; - if (tt && widget) { - dragE.preventDefault(); - dragE.stopPropagation(); - widget.drag(tt); - } - }; - $(document).on("mouseover.discourse-widget", (e) => { nodeCallback(e.target, MOUSE_OVER_ATTRIBUTE_NAME, (w) => w.mouseOver(e), { rerender: false, @@ -132,38 +196,6 @@ WidgetClickHook.setupDocumentCallback = function () { }); }); - document.addEventListener("touchmove", onDrag, { - passive: false, - capture: true, - }); - - $(document).on( - "mousedown.discource-widget-drag, touchstart.discourse-widget-drag", - (e) => { - cancelDrag(e); - widget = findWidget(e.target, DRAG_ATTRIBUTE_NAME); - if (widget) { - e.preventDefault(); - e.stopPropagation(); - _dragging = widget; - $("body").addClass("widget-dragging"); - $(document).on(DRAG_NAME, (dragE) => { - if (widget) { - widget.drag(dragE); - } - }); - } - } - ); - - $(document).on( - "mouseup.discourse-widget-drag, touchend.discourse-widget-drag", - (e) => { - widget = null; - cancelDrag(e); - } - ); - $(document).on("dblclick.discourse-widget", (e) => { nodeCallback(e.target, DOUBLE_CLICK_ATTRIBUTE_NAME, (w) => w.doubleClick(e) @@ -224,12 +256,6 @@ WidgetClickHook.setupDocumentCallback = function () { }); }); - $(document).on("touchstart.discourse-widget", (e) => { - nodeCallback(e.target, TOUCH_START_ATTRIBUTE_NAME, (w) => w.touchStart(e), { - rerender: false, - }); - }); - $(document).on("touchend.discourse-widget", (e) => { nodeCallback(e.target, TOUCH_END_ATTRIBUTE_NAME, (w) => w.touchEnd(e), { rerender: false, diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js index fcbb4263b8..63c76f053a 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js @@ -10,27 +10,27 @@ const LIKE_ACTION = 2; const VIBRATE_DURATION = 5; const _builders = {}; -const _extraButtons = {}; +let _extraButtons = {}; export let apiExtraButtons = {}; +let _buttonsToRemove = {}; export function addButton(name, builder) { _extraButtons[name] = builder; } export function resetPostMenuExtraButtons() { - Object.keys(apiExtraButtons).forEach((button) => { - removeButton(button); - }); - + _buttonsToRemove = {}; apiExtraButtons = {}; + _extraButtons = {}; } -export function removeButton(name) { - if (_extraButtons[name]) { - delete _extraButtons[name]; - } - if (_builders[name]) { - delete _builders[name]; +export function removeButton(name, callback) { + if (callback) { + _buttonsToRemove[name] = callback; + } else { + _buttonsToRemove[name] = () => { + return true; + }; } } @@ -40,8 +40,22 @@ function registerButton(name, builder) { export function buildButton(name, widget) { let { attrs, state, siteSettings, settings, currentUser } = widget; + + let shouldAddButton = true; + + if (_buttonsToRemove[name]) { + shouldAddButton = !_buttonsToRemove[name]( + attrs, + state, + siteSettings, + settings, + currentUser + ); + } + let builder = _builders[name]; - if (builder) { + + if (shouldAddButton && builder) { let button = builder(attrs, state, siteSettings, settings, currentUser); if (button && !button.id) { button.id = name; @@ -498,7 +512,19 @@ export default createWidget("post-menu", { } Object.values(_extraButtons).forEach((builder) => { - if (builder) { + let shouldAddButton = true; + + if (_buttonsToRemove[name]) { + shouldAddButton = !_buttonsToRemove[name]( + attrs, + this.state, + this.siteSettings, + this.settings, + this.currentUser + ); + } + + if (shouldAddButton && builder) { const buttonAtts = builder( attrs, this.state, diff --git a/app/assets/javascripts/discourse/app/widgets/post-stream.js b/app/assets/javascripts/discourse/app/widgets/post-stream.js index 7d2c8afc75..69d532b145 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-stream.js +++ b/app/assets/javascripts/discourse/app/widgets/post-stream.js @@ -189,7 +189,8 @@ export default createWidget("post-stream", { const posts = attrs.posts || []; const postArray = posts.toArray(); const postArrayLength = postArray.length; - const maxPostNumber = postArray[postArrayLength - 1].post_number; + const maxPostNumber = + postArrayLength > 0 ? postArray[postArrayLength - 1].post_number : 0; const result = []; const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {}; const after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {}; diff --git a/app/assets/javascripts/discourse/app/widgets/post.js b/app/assets/javascripts/discourse/app/widgets/post.js index 3f3a0ceadc..a5f7f4b3b9 100644 --- a/app/assets/javascripts/discourse/app/widgets/post.js +++ b/app/assets/javascripts/discourse/app/widgets/post.js @@ -388,8 +388,20 @@ createWidget("post-group-request", { createWidget("post-contents", { buildKey: (attrs) => `post-contents-${attrs.id}`, - defaultState() { - return { expandedFirstPost: false, repliesBelow: [] }; + defaultState(attrs) { + const defaultState = { + expandedFirstPost: false, + repliesBelow: [], + }; + + if (this.siteSettings.enable_filtered_replies_view) { + const topicController = this.register.lookup("controller:topic"); + + defaultState.filteredRepliesShown = + topicController.replies_to_post_number === attrs.post_number.toString(); + } + + return defaultState; }, buildClasses(attrs) { @@ -471,9 +483,11 @@ createWidget("post-contents", { ) { controller.send("cancelFilter", currentFilterPostNumber); this.state.filteredRepliesShown = false; + return Promise.resolve(); } else { this.state.filteredRepliesShown = true; - post + + return post .get("topic.postStream") .filterReplies(post.post_number, post.id) .then(() => { diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index f3f0b7af32..ae3d08bbe2 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -8,10 +8,12 @@ import { dateNode } from "discourse/helpers/node"; import { emojiUnescape } from "discourse/lib/text"; import getURL from "discourse-common/lib/get-url"; import { h } from "virtual-dom"; +import hbs from "discourse/widgets/hbs-compiler"; import highlightSearch from "discourse/lib/highlight-search"; import { iconNode } from "discourse-common/lib/icon-library"; import renderTag from "discourse/lib/render-tag"; import { MODIFIER_REGEXP } from "discourse/widgets/search-menu"; +import User from "discourse/models/user"; const suggestionShortcuts = [ "in:title", @@ -498,15 +500,14 @@ createWidget("search-menu-assistant", { createWidget("search-menu-initial-options", { tagName: "ul.search-menu-initial-options", + services: ["search"], html(attrs) { if (attrs.term?.match(MODIFIER_REGEXP)) { return this.defaultRow(attrs.term); } - const service = this.register.lookup("search-service:main"); - const ctx = service.get("searchContext"); - + const ctx = this.search.searchContext; const content = []; if (attrs.term || ctx) { @@ -586,6 +587,14 @@ createWidget("search-menu-initial-options", { if (content.length === 0) { content.push(this.attach("random-quick-tip")); + + if (this.currentUser && this.siteSettings.log_search_queries) { + if (this.currentUser.recent_searches?.length) { + content.push(this.attach("search-menu-recent-searches")); + } else { + this.loadRecentSearches(); + } + } } return content; @@ -603,6 +612,22 @@ createWidget("search-menu-initial-options", { ], }); }, + + refreshSearchMenuResults() { + this.scheduleRerender(); + }, + + loadRecentSearches() { + User.loadRecentSearches().then((result) => { + if (result.success && result.recent_searches?.length) { + this.currentUser.set( + "recent_searches", + Object.assign(result.recent_searches) + ); + this.scheduleRerender(); + } + }); + }, }); createWidget("search-menu-assistant-item", { @@ -613,7 +638,7 @@ createWidget("search-menu-assistant-item", { const attributes = {}; attributes.href = "#"; - let content = [iconNode("search")]; + let content = [iconNode(attrs.icon || "search")]; if (prefix) { content.push(h("span.search-item-prefix", `${prefix} `)); @@ -703,3 +728,35 @@ createWidget("random-quick-tip", { } }, }); + +createWidget("search-menu-recent-searches", { + tagName: "div.search-menu-recent", + + template: hbs` +
+

{{i18n "search.recent"}}

+ {{flat-button + className="clear-recent-searches" + title="search.clear_recent" + icon="times" + action="clearRecent" + }} +
+ + {{#each this.currentUser.recent_searches as |slug|}} + {{attach + widget="search-menu-assistant-item" + attrs=(hash slug=slug icon="history") + }} + {{/each}} + `, + + clearRecent() { + return User.resetRecentSearches().then((result) => { + if (result.success) { + this.currentUser.recent_searches.clear(); + this.sendWidgetAction("refreshSearchMenuResults"); + } + }); + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js index ad03c11b4a..78f16baf16 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js @@ -1,21 +1,27 @@ -import { isValidSearchTerm, searchForTerm } from "discourse/lib/search"; +import { + isValidSearchTerm, + searchForTerm, + updateRecentSearches, +} from "discourse/lib/search"; import DiscourseURL from "discourse/lib/url"; import { createWidget } from "discourse/widgets/widget"; import discourseDebounce from "discourse-common/lib/debounce"; import getURL from "discourse-common/lib/get-url"; import { h } from "virtual-dom"; import { iconNode } from "discourse-common/lib/icon-library"; -import { isiPad } from "discourse/lib/utilities"; +import { isiPad, translateModKey } from "discourse/lib/utilities"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { Promise } from "rsvp"; import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import userSearch from "discourse/lib/user-search"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import { cancel } from "@ember/runloop"; +import I18n from "I18n"; const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi; const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi; const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/gi; +const SECOND_ENTER_MAX_DELAY = 15000; export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi; export const DEFAULT_TYPE_FILTER = "exclude_topics"; @@ -111,14 +117,14 @@ const SearchHelper = { if (!term) { searchData.noResults = false; - searchData.results = []; + searchData.results = {}; searchData.loading = false; searchData.invalidTerm = false; widget.scheduleRerender(); } else if (!isValidSearchTerm(term, widget.siteSettings)) { searchData.noResults = true; - searchData.results = []; + searchData.results = {}; searchData.loading = false; searchData.invalidTerm = true; @@ -184,6 +190,7 @@ const SearchHelper = { export default createWidget("search-menu", { tagName: "div.search-menu", + services: ["search"], searchData, buildKey: () => "search-menu", @@ -191,6 +198,7 @@ export default createWidget("search-menu", { defaultState(attrs) { return { inTopicContext: attrs.inTopicContext, + _lastEnterTimestamp: null, _debouncer: null, }; }, @@ -276,6 +284,11 @@ export default createWidget("search-menu", { this.state.inTopicContext && (!SearchHelper.includesTopics() || !searchData.term) ) { + const isMobileDevice = this.site.isMobileDevice; + + if (!isMobileDevice) { + results.push(this.attach("browser-search-tip")); + } return results; } @@ -304,13 +317,6 @@ export default createWidget("search-menu", { this.triggerSearch(); }, - searchService() { - if (!this._searchService) { - this._searchService = this.register.lookup("search-service:main"); - } - return this._searchService; - }, - html(attrs, state) { if (attrs.inTopicContext === false) { state.inTopicContext = false; @@ -418,13 +424,23 @@ export default createWidget("search-menu", { const searchInput = document.querySelector("#search-term"); if (e.which === 13 && e.target === searchInput) { + const recentEnterHit = + this.state._lastEnterTimestamp && + Date.now() - this.state._lastEnterTimestamp < SECOND_ENTER_MAX_DELAY; + // same combination as key-enter-escape mixin - if (e.ctrlKey || e.metaKey || (isiPad() && e.altKey)) { + if ( + e.ctrlKey || + e.metaKey || + (isiPad() && e.altKey) || + (searchData.typeFilter !== DEFAULT_TYPE_FILTER && recentEnterHit) + ) { this.fullSearch(); } else { searchData.typeFilter = null; this.triggerSearch(); } + this.state._lastEnterTimestamp = Date.now(); } if (e.target === searchInput && e.which === 8 /* backspace */) { @@ -438,12 +454,15 @@ export default createWidget("search-menu", { searchData.noResults = false; if (SearchHelper.includesTopics()) { if (this.state.inTopicContext) { - this.searchService().set("highlightTerm", searchData.term); + this.search.set("highlightTerm", searchData.term); } searchData.loading = true; cancel(this.state._debouncer); SearchHelper.perform(this); + if (this.currentUser) { + updateRecentSearches(this.currentUser, searchData.term); + } } else { searchData.loading = false; if (!this.state.inTopicContext) { @@ -477,7 +496,6 @@ export default createWidget("search-menu", { }, fullSearch() { - searchData.results = []; searchData.loading = false; SearchHelper.cancel(); const url = this.fullSearchUrl(); @@ -489,9 +507,26 @@ export default createWidget("search-menu", { searchContext() { if (this.state.inTopicContext) { - return this.searchService().get("searchContext"); + return this.search.searchContext; } return false; }, }); + +createWidget("browser-search-tip", { + buildKey: () => "browser-search-tip", + tagName: "div.browser-search-tip", + + html() { + return [ + h( + "span.tip-label", + I18n.t("search.browser_tip", { + modifier: translateModKey("Meta"), + }) + ), + h("span.tip-description", I18n.t("search.browser_tip_description")), + ]; + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/user-menu.js b/app/assets/javascripts/discourse/app/widgets/user-menu.js index fb1de66233..47d3f4496d 100644 --- a/app/assets/javascripts/discourse/app/widgets/user-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/user-menu.js @@ -1,6 +1,7 @@ import { later } from "@ember/runloop"; import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; +import showModal from "discourse/lib/show-modal"; const UserMenuAction = { QUICK_ACCESS: "quickAccess", @@ -249,7 +250,18 @@ export default createWidget("user-menu", { }, dismissNotifications() { - return this.state.markRead(); + const unreadHighPriorityNotifications = this.currentUser.get( + "unread_high_priority_notifications" + ); + + if (unreadHighPriorityNotifications > 0) { + return showModal("dismiss-notification-confirmation").setProperties({ + count: unreadHighPriorityNotifications, + dismissNotifications: () => this.state.markRead(), + }); + } else { + return this.state.markRead(); + } }, itemsLoaded({ hasUnread, markRead }) { diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 59c48a1463..d6301bd569 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -25,6 +25,9 @@ module.exports = function (defaults) { // This forces the use of `fast-sourcemap-concat` which works in production. enabled: true, }, + autoImport: { + forbidEval: true, + }, }); // Ember CLI does this by default for the app tree, but for our extra bundles we diff --git a/app/assets/javascripts/discourse/lib/bootstrap-json/index.js b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js index 6042435728..197f9975be 100644 --- a/app/assets/javascripts/discourse/lib/bootstrap-json/index.js +++ b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js @@ -1,11 +1,12 @@ "use strict"; -const bent = require("bent"); -const getJSON = bent("json"); +const express = require("express"); +const fetch = require("node-fetch"); const { encode } = require("html-entities"); const cleanBaseURL = require("clean-base-url"); const path = require("path"); -const fs = require("fs"); +const { promises: fs } = require("fs"); +const { JSDOM } = require("jsdom"); // via https://stackoverflow.com/a/6248722/165668 function generateUID() { @@ -16,11 +17,6 @@ function generateUID() { return firstPart + secondPart; } -const IGNORE_PATHS = [ - /\/ember-cli-live-reload\.js$/, - /\/session\/[^\/]+\/become$/, -]; - function htmlTag(buffer, bootstrap) { let classList = ""; if (bootstrap.html_classes) { @@ -127,7 +123,9 @@ function bodyFooter(buffer, bootstrap, headers) { let v = generateUID(); buffer.push(` - + `); } @@ -171,91 +169,118 @@ function replaceIn(bootstrap, template, id, headers, baseURL) { return template.replace(``, contents); } -async function applyBootstrap(bootstrap, template, response, baseURL) { - // If our initial page added some preload data let's not lose that. - let json = await response.json(); - if (json && json.preloaded) { - bootstrap.preloaded = Object.assign(json.preloaded, bootstrap.preloaded); - } +function extractPreloadJson(html) { + const dom = new JSDOM(html); + return dom.window.document.querySelector("#data-preloaded")?.dataset + ?.preloaded; +} + +async function applyBootstrap(bootstrap, template, response, baseURL, preload) { + bootstrap.preloaded = Object.assign(JSON.parse(preload), bootstrap.preloaded); Object.keys(BUILDERS).forEach((id) => { - template = replaceIn(bootstrap, template, id, response, baseURL); + template = replaceIn(bootstrap, template, id, response.headers, baseURL); }); return template; } -function buildFromBootstrap(assetPath, proxy, baseURL, req, response) { - // eslint-disable-next-line - return new Promise((resolve, reject) => { - fs.readFile( - path.join(process.cwd(), "dist", assetPath), - "utf8", - (err, template) => { - let url = `${proxy}${baseURL}bootstrap.json`; - let queryLoc = req.url.indexOf("?"); - if (queryLoc !== -1) { - url += req.url.substr(queryLoc); - } - - getJSON(url, null, req.headers) - .then((json) => { - return applyBootstrap(json.bootstrap, template, response, baseURL); - }) - .then(resolve) - .catch((e) => { - reject( - `Could not get ${proxy}${baseURL}bootstrap.json\n\n${e.toString()}` - ); - }); - } +async function buildFromBootstrap(proxy, baseURL, req, response, preload) { + try { + const template = await fs.readFile( + path.join(process.cwd(), "dist", "index.html"), + "utf8" ); - }); + + let url = new URL(`${proxy}${baseURL}bootstrap.json`); + url.searchParams.append("for_url", req.url); + + const res = await fetch(url, { headers: req.headers }); + const json = await res.json(); + + return applyBootstrap(json.bootstrap, template, response, baseURL, preload); + } catch (error) { + throw new Error( + `Could not get ${proxy}${baseURL}bootstrap.json\n\n${error}` + ); + } } -async function handleRequest(assetPath, proxy, baseURL, req, res) { - if (assetPath.endsWith("tests/index.html")) { - return; +async function handleRequest(proxy, baseURL, req, res) { + const originalHost = req.headers.host; + req.headers.host = new URL(proxy).host; + + if (req.headers["Origin"]) { + req.headers["Origin"] = req.headers["Origin"] + .replace(req.headers.host, originalHost) + .replace(/^https/, "http"); } - if (assetPath.endsWith("index.html")) { - try { - // Avoid Ember CLI's proxy if doing a GET, since Discourse depends on some non-XHR - // GET requests to work. - if (req.method === "GET") { - let url = `${proxy}${req.path}`; + if (req.headers["Referer"]) { + req.headers["Referer"] = req.headers["Referer"] + .replace(req.headers.host, originalHost) + .replace(/^https/, "http"); + } - let queryLoc = req.url.indexOf("?"); - if (queryLoc !== -1) { - url += req.url.substr(queryLoc); - } + let url = `${proxy}${req.path}`; + const queryLoc = req.url.indexOf("?"); + if (queryLoc !== -1) { + url += req.url.substr(queryLoc); + } - req.headers["X-Discourse-Ember-CLI"] = "true"; - let get = bent("GET", [200, 301, 302, 303, 307, 308, 404, 403, 500]); - let response = await get(url, null, req.headers); - res.set(response.headers); - res.set("content-type", "text/html"); - if (response.headers["x-discourse-bootstrap-required"] === "true") { - req.headers["X-Discourse-Asset-Path"] = req.path; - let html = await buildFromBootstrap( - assetPath, - proxy, - baseURL, - req, - response - ); - return res.send(html); - } - res.status(response.status); - res.send(await response.text()); - } - } catch (e) { - res.send(` - -

Discourse Build Error

-
${e.toString()}
- - `); - } + if (req.method === "GET") { + req.headers["X-Discourse-Ember-CLI"] = "true"; + } + + const response = await fetch(url, { + method: req.method, + body: /GET|HEAD/.test(req.method) ? null : req.body, + headers: req.headers, + }); + + response.headers.forEach((value, header) => { + res.set(header, value); + }); + res.set("content-encoding", null); + + const location = response.headers.get("location"); + if (location) { + const newLocation = location.replace(proxy, `http://${originalHost}`); + res.set("location", newLocation); + } + + const csp = response.headers.get("content-security-policy"); + if (csp) { + const emberCliAdditions = [ + `http://${originalHost}/assets/`, + `http://${originalHost}/ember-cli-live-reload.js`, + `http://${originalHost}/_lr/`, + ]; + const newCSP = csp + .replace(new RegExp(proxy, "g"), `http://${originalHost}`) + .replace( + new RegExp("script-src ", "g"), + `script-src ${emberCliAdditions.join(" ")} ` + ); + res.set("content-security-policy", newCSP); + } + + const isHTML = response.headers["content-type"]?.startsWith("text/html"); + const responseText = await response.text(); + const preload = isHTML ? extractPreloadJson(responseText) : null; + + if (preload) { + const html = await buildFromBootstrap( + proxy, + baseURL, + req, + response, + preload + ); + res.set("content-type", "text/html"); + res.send(html); + } else { + res.status(response.status); + res.send(responseText); } } @@ -267,12 +292,11 @@ module.exports = { }, serverMiddleware(config) { - let proxy = config.options.proxy; - let app = config.app; - let options = config.options; + const app = config.app; + let { proxy, rootURL, baseURL } = config.options; if (!proxy) { - // eslint-disable-next-line + // eslint-disable-next-line no-console console.error(` Discourse can't be run without a \`--proxy\` setting, because it needs a Rails application to serve API requests. For example: @@ -281,31 +305,22 @@ to serve API requests. For example: throw "--proxy argument is required"; } - let watcher = options.watcher; + baseURL = rootURL === "" ? "/" : cleanBaseURL(rootURL || baseURL); - let baseURL = - options.rootURL === "" - ? "/" - : cleanBaseURL(options.rootURL || options.baseURL); + const rawMiddleware = express.raw({ type: () => true, limit: "100mb" }); - app.use(async (req, res, next) => { + app.use(rawMiddleware, async (req, res, next) => { try { - const results = await watcher; - if (this.shouldHandleRequest(req, options)) { - let assetPath = req.path.slice(baseURL.length); - let isFile = false; - - try { - isFile = fs - .statSync(path.join(results.directory, assetPath)) - .isFile(); - } catch (err) {} - - if (!isFile) { - assetPath = "index.html"; - } - await handleRequest(assetPath, proxy, baseURL, req, res); + if (this.shouldHandleRequest(req)) { + await handleRequest(proxy, baseURL, req, res); } + } catch (error) { + res.send(` + +

Discourse Build Error

+
${error}
+ + `); } finally { if (!res.headersSent) { return next(); @@ -314,25 +329,28 @@ to serve API requests. For example: }); }, - shouldHandleRequest(req) { - let acceptHeaders = req.headers.accept || []; - let hasHTMLHeader = acceptHeaders.indexOf("text/html") !== -1; - if (req.method !== "GET") { - return false; - } - if (!hasHTMLHeader) { + shouldHandleRequest(request) { + if (request.path === "/tests/index.html") { return false; } - if (IGNORE_PATHS.some((ip) => ip.test(req.path))) { + if (request.get("Accept") && request.get("Accept").includes("text/html")) { + return true; + } + + const contentType = request.get("Content-Type"); + if (!contentType) { return false; } - if (req.path.endsWith(".json")) { - return false; + if ( + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data") || + contentType.includes("application/json") + ) { + return true; } - let baseURLRegexp = new RegExp(`^/`); - return baseURLRegexp.test(req.path); + return false; }, }; diff --git a/app/assets/javascripts/discourse/lib/bootstrap-json/package.json b/app/assets/javascripts/discourse/lib/bootstrap-json/package.json index 50d5b34ef2..abc04bd351 100644 --- a/app/assets/javascripts/discourse/lib/bootstrap-json/package.json +++ b/app/assets/javascripts/discourse/lib/bootstrap-json/package.json @@ -11,7 +11,7 @@ ] }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" } diff --git a/app/assets/javascripts/discourse/lib/translation-plugin.js b/app/assets/javascripts/discourse/lib/translation-plugin.js index ca907b69dd..a8551d0b9e 100644 --- a/app/assets/javascripts/discourse/lib/translation-plugin.js +++ b/app/assets/javascripts/discourse/lib/translation-plugin.js @@ -4,6 +4,8 @@ const fs = require("fs"); const concat = require("broccoli-concat"); const mergeTrees = require("broccoli-merge-trees"); const MessageFormat = require("messageformat"); +const deepmerge = require("deepmerge"); +const glob = require("glob"); let built = false; @@ -18,6 +20,10 @@ class TranslationPlugin extends Plugin { } replaceMF(formats, input, path = []) { + if (!input) { + return; + } + Object.keys(input).forEach((key) => { let value = input[key]; @@ -40,10 +46,14 @@ class TranslationPlugin extends Plugin { return; } - let file = this.inputPaths[0] + "/" + this.inputFile; + let parsed = {}; - let yaml = fs.readFileSync(file, { encoding: "UTF-8" }); - let parsed = Yaml.load(yaml); + this.inputPaths.forEach((path) => { + let file = path + "/" + this.inputFile; + let yaml = fs.readFileSync(file, { encoding: "UTF-8" }); + let loaded = Yaml.load(yaml, { json: true }); + parsed = deepmerge(parsed, loaded); + }); let extras = { en: { @@ -82,11 +92,14 @@ module.exports = function translatePlugin(...params) { }; module.exports.createI18nTree = function (discourseRoot, vendorJs) { - let en = new TranslationPlugin( - [discourseRoot + "/config/locales"], - "client.en.yml" + let translations = [discourseRoot + "/config/locales"].concat( + glob + .sync(discourseRoot + "/plugins/*/config/locales/client.en.yml") + .map((f) => f.replace(/\/client\.en\.yml$/, "")) ); + let en = new TranslationPlugin(translations, "client.en.yml"); + return concat( mergeTrees([ vendorJs, diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 4da06cee00..32b109253a 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -21,15 +21,16 @@ "@ember/optional-features": "^1.1.0", "@ember/test-helpers": "^2.2.0", "@glimmer/component": "^1.0.0", - "@popperjs/core": "2.9.3", + "@popperjs/core": "2.10.2", "@uppy/aws-s3": "^2.0.4", "@uppy/aws-s3-multipart": "^2.1.0", "@uppy/core": "^2.1.0", "@uppy/drop-target": "^1.1.0", + "@uppy/utils": "^4.0.3", "@uppy/xhr-upload": "^2.0.4", "admin": "^1.0.0", - "bent": "^7.3.12", "broccoli-asset-rev": "^3.0.0", + "deepmerge": "^4.2.2", "discourse-common": "^1.0.0", "discourse-hbr": "^1.0.0", "discourse-widget-hbs": "^1.0.0", @@ -49,12 +50,15 @@ "ember-maybe-import-regenerator": "^0.1.6", "ember-qunit": "^5.1.2", "ember-source": "~3.15.0", + "ember-test-selectors": "^6.0.0", "eslint": "^7.27.0", "html-entities": "^2.1.0", "js-yaml": "^4.0.0", + "jsdom": "^18.1.1", "loader.js": "^4.7.0", "message-bus-client": "^3.3.0", "messageformat": "0.1.5", + "node-fetch": "^2.6.6", "pretender": "^3.4.7", "pretty-text": "^1.0.0", "qunit": "^2.14.0", @@ -65,7 +69,7 @@ "virtual-dom": "^2.1.1" }, "engines": { - "node": ">= 12.*", + "node": "12.* || 14.* || >= 16", "npm": "please-use-yarn", "yarn": ">= 1.21.1" }, diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js index cd0a9bf318..aae3996900 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js @@ -10,6 +10,7 @@ import I18n from "I18n"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; import topicFixtures from "discourse/tests/fixtures/topic"; +import { cloneJSON } from "discourse-common/lib/object"; async function openBookmarkModal(postNumber = 1) { if (exists(`#post_${postNumber} button.show-more-actions`)) { @@ -58,11 +59,11 @@ async function testTopicLevelBookmarkButtonIcon(assert, postNumber) { acceptance("Bookmarking", function (needs) { needs.user(); - const topicResponse = topicFixtures["/t/280/1.json"]; + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); topicResponse.post_stream.posts[0].cooked += ` - + January 15, 2036 12:35 AM @@ -71,7 +72,7 @@ acceptance("Bookmarking", function (needs) { topicResponse.post_stream.posts[1].cooked += ` - + Today 10:30 AM diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js deleted file mode 100644 index 37ed65aca3..0000000000 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-attachment-test.js +++ /dev/null @@ -1,326 +0,0 @@ -import { - acceptance, - exists, - query, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { click, fillIn, visit } from "@ember/test-helpers"; -import bootbox from "bootbox"; -import { test } from "qunit"; - -function pretender(server, helper) { - server.post("/uploads/lookup-urls", () => { - return helper.response([ - { - short_url: "upload://asdsad.png", - url: "/secure-media-uploads/default/3X/1/asjdiasjdiasida.png", - short_path: "/uploads/short-url/asdsad.png", - }, - ]); - }); -} - -async function writeInComposer(assert) { - await visit("/t/internationalization-localization/280"); - await click("#topic-footer-buttons .btn.create"); - - await fillIn(".d-editor-input", "[test](upload://abcdefg.png)"); - - assert.strictEqual( - queryAll(".d-editor-preview:visible").html().trim(), - '

test

' - ); - - await fillIn(".d-editor-input", "[test|attachment](upload://asdsad.png)"); -} - -acceptance("Composer Attachment - Cooking", function (needs) { - needs.user(); - needs.pretender(pretender); - - test("attachments are cooked properly", async function (assert) { - await writeInComposer(assert); - assert.strictEqual( - queryAll(".d-editor-preview:visible").html().trim(), - '

test

' - ); - }); -}); - -acceptance("Composer Attachment - Secure Media Enabled", function (needs) { - needs.user(); - needs.settings({ secure_media: true }); - needs.pretender(pretender); - - test("attachments are cooked properly when secure media is enabled", async function (assert) { - await writeInComposer(assert); - assert.strictEqual( - queryAll(".d-editor-preview:visible").html().trim(), - '

test

' - ); - }); -}); - -acceptance("Composer Attachment - Upload Placeholder", function (needs) { - needs.user(); - - test("should insert a newline before and after an image when pasting into an empty composer", async function (assert) { - await visit("/"); - await click("#create-topic"); - const image = createImage("avatar.png", "/images/avatar.png?1", 200, 300); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: avatar.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "![avatar|200x300](/images/avatar.png?1)\n" - ); - }); - - test("should insert a newline after an image when pasting into a blank line", async function (assert) { - await visit("/"); - await click("#create-topic"); - await fillIn(".d-editor-input", "The image:\n"); - - const image = createImage("avatar.png", "/images/avatar.png?1", 200, 300); - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image:\n[Uploading: avatar.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image:\n![avatar|200x300](/images/avatar.png?1)\n" - ); - }); - - test("should insert a newline before and after an image when pasting into a non blank line", async function (assert) { - await visit("/"); - await click("#create-topic"); - await fillIn(".d-editor-input", "The image:"); - - const image = createImage("avatar.png", "/images/avatar.png?1", 200, 300); - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image:\n[Uploading: avatar.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image:\n![avatar|200x300](/images/avatar.png?1)\n" - ); - }); - - test("should insert a newline before and after an image when pasting with cursor in the middle of the line", async function (assert) { - await visit("/"); - await click("#create-topic"); - await fillIn(".d-editor-input", "The image Text after the image."); - const textArea = query(".d-editor-input"); - textArea.selectionStart = 10; - textArea.selectionEnd = 10; - - const image = createImage("avatar.png", "/images/avatar.png?1", 200, 300); - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image \n[Uploading: avatar.png...]()\nText after the image." - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image \n![avatar|200x300](/images/avatar.png?1)\nText after the image." - ); - }); - - test("should insert a newline before and after an image when pasting with text selected", async function (assert) { - await visit("/"); - await click("#create-topic"); - const image = createImage("avatar.png", "/images/avatar.png?1", 200, 300); - await fillIn( - ".d-editor-input", - "The image [paste here] Text after the image." - ); - const textArea = query(".d-editor-input"); - textArea.selectionStart = 10; - textArea.selectionEnd = 23; - - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image \n[Uploading: avatar.png...]()\n Text after the image." - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "The image \n![avatar|200x300](/images/avatar.png?1)\n Text after the image." - ); - }); - - test("pasting several images", async function (assert) { - await visit("/"); - await click("#create-topic"); - - const image1 = createImage("test.png", "/images/avatar.png?1", 200, 300); - const image2 = createImage("test.png", "/images/avatar.png?2", 100, 200); - const image3 = createImage("image.png", "/images/avatar.png?3", 300, 400); - const image4 = createImage("image.png", "/images/avatar.png?4", 300, 400); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image1); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image2); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n[Uploading: test.png(1)...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image4); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n[Uploading: test.png(1)...]()\n[Uploading: image.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image3); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n[Uploading: test.png(1)...]()\n[Uploading: image.png...]()\n[Uploading: image.png(1)...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image2); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n![test|100x200](/images/avatar.png?2)\n[Uploading: image.png...]()\n[Uploading: image.png(1)...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image3); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: test.png...]()\n![test|100x200](/images/avatar.png?2)\n[Uploading: image.png...]()\n![image|300x400](/images/avatar.png?3)\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image1); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "![test|200x300](/images/avatar.png?1)\n![test|100x200](/images/avatar.png?2)\n[Uploading: image.png...]()\n![image|300x400](/images/avatar.png?3)\n" - ); - }); - - test("should accept files with unescaped characters", async function (assert) { - await visit("/"); - await click("#create-topic"); - - const image = createImage("ima++ge.png", "/images/avatar.png?4", 300, 400); - - await queryAll(".wmd-controls").trigger("fileuploadsend", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "[Uploading: ima++ge.png...]()\n" - ); - - await queryAll(".wmd-controls").trigger("fileuploaddone", image); - assert.strictEqual( - queryAll(".d-editor-input").val(), - "![ima++ge|300x400](/images/avatar.png?4)\n" - ); - }); -}); - -function createImage(name, url, width, height) { - const file = new Blob([""], { type: "image/png" }); - file.name = name; - return { - files: [file], - result: { - original_filename: name, - thumbnail_width: width, - thumbnail_height: height, - url, - }, - }; -} - -acceptance("Composer Attachment - Upload Handler", function (needs) { - needs.user(); - needs.hooks.beforeEach(() => { - withPluginApi("0.8.14", (api) => { - api.addComposerUploadHandler(["png"], (file) => { - bootbox.alert(`This is an upload handler test for ${file.name}`); - }); - }); - }); - - test("should handle a single file being uploaded with the extension handler", async function (assert) { - await visit("/"); - await click("#create-topic"); - const image = createImage( - "handlertest.png", - "/images/avatar.png?1", - 200, - 300 - ); - await fillIn(".d-editor-input", "This is a handler test."); - - await queryAll(".wmd-controls").trigger("fileuploadsubmit", image); - assert.strictEqual( - queryAll(".bootbox .modal-body").html(), - "This is an upload handler test for handlertest.png", - "it should show the bootbox triggered by the upload handler" - ); - await click(".modal-footer .btn"); - }); -}); - -acceptance("Composer Attachment - File input", function (needs) { - needs.user(); - - test("shouldn't add to DOM the hidden file input if uploads aren't allowed", async function (assert) { - this.siteSettings.authorized_extensions = ""; - await visit("/"); - await click("#create-topic"); - - assert.notOk(exists("input#file-uploader")); - }); - - test("should fill the accept attribute with allowed file extensions", async function (assert) { - this.siteSettings.authorized_extensions = "jpg|jpeg|png"; - await visit("/"); - await click("#create-topic"); - - assert.ok(exists("input#file-uploader"), "An input is rendered"); - assert.strictEqual( - query("input#file-uploader").accept, - ".jpg,.jpeg,.png", - "Accepted values are correct" - ); - }); - - test("the hidden file input shouldn't have the accept attribute if any file extension is allowed", async function (assert) { - this.siteSettings.authorized_extensions = "jpg|jpeg|png|*"; - await visit("/"); - await click("#create-topic"); - - assert.ok(exists("input#file-uploader"), "An input is rendered"); - assert.notOk( - query("input#file-uploader").hasAttribute("accept"), - "The input doesn't contain the accept attribute" - ); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js new file mode 100644 index 0000000000..b5148fa888 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-image-preview-test.js @@ -0,0 +1,362 @@ +import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; +import { + acceptance, + count, + exists, + invisible, + query, + queryAll, + visible, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Composer - Image Preview", function (needs) { + needs.user(); + needs.settings({ enable_whispers: true }); + needs.site({ can_tag_topics: true }); + needs.pretender((server, helper) => { + server.post("/uploads/lookup-urls", () => { + return helper.response([]); + }); + server.get("/posts/419", () => { + return helper.response({ id: 419 }); + }); + server.get("/u/is_local_username", () => { + return helper.response({ + valid: [], + valid_groups: ["staff"], + mentionable_groups: [{ name: "staff", user_count: 30 }], + cannot_see: [], + max_users_notified_per_group_mention: 100, + }); + }); + }); + + const assertImageResized = (assert, uploads) => { + assert.strictEqual( + queryAll(".d-editor-input").val(), + uploads.join("\n"), + "it resizes uploaded image" + ); + }; + + test("Image resizing buttons", async function (assert) { + await visit("/"); + await click("#create-topic"); + + let uploads = [ + // 0 Default markdown with dimensions- should work + "![test|690x313](upload://test.png)", + // 1 Image with scaling percentage, should work + "![test|690x313,50%](upload://test.png)", + // 2 image with scaling percentage and a proceeding whitespace, should work + "![test|690x313, 50%](upload://test.png)", + // 3 No dimensions, should not work + "![test](upload://test.jpeg)", + // 4 Wrapped in backticks should not work + "`![test|690x313](upload://test.png)`", + // 5 html image - should not work + "", + // 6 two images one the same line, but both are syntactically correct - both should work + "![onTheSameLine1|200x200](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)", + // 7 & 8 Identical images - both should work + "![identicalImage|300x300](upload://identicalImage.png)", + "![identicalImage|300x300](upload://identicalImage.png)", + // 9 Image with whitespaces in alt - should work + "![image with spaces in alt|690x220](upload://test.png)", + // 10 Image with markdown title - should work + `![image|690x220](upload://test.png "image title")`, + // 11 bbcode - should not work + "[img]/images/avatar.png[/img]", + // 12 Image with data attributes + "![test|foo=bar|690x313,50%|bar=baz](upload://test.png)", + ]; + + await fillIn(".d-editor-input", uploads.join("\n")); + + assert.strictEqual( + count(".button-wrapper"), + 10, + "it adds correct amount of scaling button groups" + ); + + // Default + uploads[0] = + "![test|690x313, 50%](upload://test.png)"; + await click( + queryAll( + ".button-wrapper[data-image-index='0'] .scale-btn[data-scale='50']" + )[0] + ); + assertImageResized(assert, uploads); + + // Targets the correct image if two on the same line + uploads[6] = + "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)"; + await click( + queryAll( + ".button-wrapper[data-image-index='3'] .scale-btn[data-scale='50']" + )[0] + ); + assertImageResized(assert, uploads); + + // Try the other image on the same line + uploads[6] = + "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250, 75%](upload://onTheSameLine2.jpeg)"; + await click( + queryAll( + ".button-wrapper[data-image-index='4'] .scale-btn[data-scale='75']" + )[0] + ); + assertImageResized(assert, uploads); + + // Make sure we target the correct image if there are duplicates + uploads[7] = "![identicalImage|300x300, 50%](upload://identicalImage.png)"; + await click( + queryAll( + ".button-wrapper[data-image-index='5'] .scale-btn[data-scale='50']" + )[0] + ); + assertImageResized(assert, uploads); + + // Try the other dupe + uploads[8] = "![identicalImage|300x300, 75%](upload://identicalImage.png)"; + await click( + queryAll( + ".button-wrapper[data-image-index='6'] .scale-btn[data-scale='75']" + )[0] + ); + assertImageResized(assert, uploads); + + // Don't mess with image titles + uploads[10] = `![image|690x220, 75%](upload://test.png "image title")`; + await click( + queryAll( + ".button-wrapper[data-image-index='8'] .scale-btn[data-scale='75']" + )[0] + ); + assertImageResized(assert, uploads); + + // Keep data attributes + uploads[12] = `![test|foo=bar|690x313, 75%|bar=baz](upload://test.png)`; + await click( + queryAll( + ".button-wrapper[data-image-index='9'] .scale-btn[data-scale='75']" + )[0] + ); + assertImageResized(assert, uploads); + + await fillIn( + ".d-editor-input", + ` +![test|690x313](upload://test.png) + +\`\` + ` + ); + + assert.ok( + !exists("script"), + "it does not unescape script tags in code blocks" + ); + }); + + test("Editing alt text (with enter key) for single image in preview updates alt text in composer", async function (assert) { + const scaleButtonContainer = ".scale-btn-container"; + + const readonlyAltText = ".alt-text"; + const editAltTextButton = ".alt-text-edit-btn"; + + const altTextInput = ".alt-text-input"; + const altTextEditOk = ".alt-text-edit-ok"; + const altTextEditCancel = ".alt-text-edit-cancel"; + + await visit("/"); + + await click("#create-topic"); + await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); + + assert.equal(query(readonlyAltText).innerText, "zorro", "correct alt text"); + assert.ok(visible(readonlyAltText), "alt text is visible"); + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(invisible(altTextInput), "alt text input is hidden"); + assert.ok(invisible(altTextEditOk), "alt text edit ok button is hidden"); + assert.ok(invisible(altTextEditCancel), "alt text edit cancel is hidden"); + + await click(editAltTextButton); + + assert.ok(invisible(scaleButtonContainer), "scale buttons are hidden"); + assert.ok(invisible(readonlyAltText), "alt text is hidden"); + assert.ok(invisible(editAltTextButton), "alt text edit button is hidden"); + assert.ok(visible(altTextInput), "alt text input is visible"); + assert.ok(visible(altTextEditOk), "alt text edit ok button is visible"); + assert.ok(visible(altTextEditCancel), "alt text edit cancel is hidden"); + assert.equal( + queryAll(altTextInput).val(), + "zorro", + "correct alt text in input" + ); + + await triggerKeyEvent(altTextInput, "keypress", "[".charCodeAt(0)); + await triggerKeyEvent(altTextInput, "keypress", "]".charCodeAt(0)); + assert.equal( + queryAll(altTextInput).val(), + "zorro", + "does not input [ ] keys" + ); + + await fillIn(altTextInput, "steak"); + await triggerKeyEvent(altTextInput, "keypress", 13); + + assert.equal( + queryAll(".d-editor-input").val(), + "![steak|200x200](upload://zorro.png)", + "alt text updated" + ); + assert.equal( + query(readonlyAltText).innerText, + "steak", + "shows the alt text" + ); + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(visible(scaleButtonContainer), "scale buttons are visible"); + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(invisible(altTextInput), "alt text input is hidden"); + assert.ok(invisible(altTextEditOk), "alt text edit ok button is hidden"); + assert.ok(invisible(altTextEditCancel), "alt text edit cancel is hidden"); + }); + + test("Editing alt text (with check button) in preview updates alt text in composer", async function (assert) { + const scaleButtonContainer = ".scale-btn-container"; + const readonlyAltText = ".alt-text"; + const editAltTextButton = ".alt-text-edit-btn"; + + const altTextInput = ".alt-text-input"; + const altTextEditOk = ".alt-text-edit-ok"; + const altTextEditCancel = ".alt-text-edit-cancel"; + + await visit("/"); + + await click("#create-topic"); + await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); + + await click(editAltTextButton); + + await fillIn(altTextInput, "steak"); + await click(altTextEditOk); + + assert.equal( + queryAll(".d-editor-input").val(), + "![steak|200x200](upload://zorro.png)", + "alt text updated" + ); + assert.equal( + query(readonlyAltText).innerText, + "steak", + "shows the alt text" + ); + + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(visible(scaleButtonContainer), "scale buttons are visible"); + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(invisible(altTextInput), "alt text input is hidden"); + assert.ok(invisible(altTextEditOk), "alt text edit ok button is hidden"); + assert.ok(invisible(altTextEditCancel), "alt text edit cancel is hidden"); + }); + + test("Cancel alt text edit in preview does not update alt text in composer", async function (assert) { + const scaleButtonContainer = ".scale-btn-container"; + + const readonlyAltText = ".alt-text"; + const editAltTextButton = ".alt-text-edit-btn"; + + const altTextInput = ".alt-text-input"; + const altTextEditOk = ".alt-text-edit-ok"; + const altTextEditCancel = ".alt-text-edit-cancel"; + + await visit("/"); + + await click("#create-topic"); + await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); + + await click(editAltTextButton); + + await fillIn(altTextInput, "steak"); + await click(altTextEditCancel); + + assert.equal( + queryAll(".d-editor-input").val(), + "![zorro|200x200](upload://zorro.png)", + "alt text not updated" + ); + assert.equal( + query(readonlyAltText).innerText, + "zorro", + "shows the unedited alt text" + ); + + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(visible(scaleButtonContainer), "scale buttons are visible"); + assert.ok(visible(editAltTextButton), "alt text edit button is visible"); + assert.ok(invisible(altTextInput), "alt text input is hidden"); + assert.ok(invisible(altTextEditOk), "alt text edit ok button is hidden"); + assert.ok(invisible(altTextEditCancel), "alt text edit cancel is hidden"); + }); + + test("Editing alt text for one of two images in preview updates correct alt text in composer", async function (assert) { + const editAltTextButton = ".alt-text-edit-btn"; + const altTextInput = ".alt-text-input"; + + await visit("/"); + await click("#create-topic"); + + await fillIn( + ".d-editor-input", + `![zorro|200x200](upload://zorro.png) ![not-zorro|200x200](upload://not-zorro.png)` + ); + await click(editAltTextButton); + + await fillIn(altTextInput, "tomtom"); + await triggerKeyEvent(altTextInput, "keypress", 13); + + assert.equal( + queryAll(".d-editor-input").val(), + `![tomtom|200x200](upload://zorro.png) ![not-zorro|200x200](upload://not-zorro.png)`, + "the correct image's alt text updated" + ); + }); + + test("Deleting alt text for image empties alt text in composer and allows further modification", async function (assert) { + const altText = ".alt-text"; + const editAltTextButton = ".alt-text-edit-btn"; + const altTextInput = ".alt-text-input"; + + await visit("/"); + + await click("#create-topic"); + await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); + + await click(editAltTextButton); + + await fillIn(altTextInput, ""); + await triggerKeyEvent(altTextInput, "keypress", 13); + + assert.equal( + queryAll(".d-editor-input").val(), + "![|200x200](upload://zorro.png)", + "alt text updated" + ); + assert.equal(query(altText).innerText, "", "shows the alt text"); + + await click(editAltTextButton); + + await fillIn(altTextInput, "tomtom"); + await triggerKeyEvent(altTextInput, "keypress", 13); + + assert.equal( + queryAll(".d-editor-input").val(), + "![tomtom|200x200](upload://zorro.png)", + "alt text updated" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index 44d87a77d3..00d1eea075 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -1,3 +1,10 @@ +import { run } from "@ember/runloop"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { toggleCheckDraftPopup } from "discourse/controllers/composer"; +import LinkLookup from "discourse/lib/link-lookup"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { CREATE_TOPIC, NEW_TOPIC_KEY } from "discourse/models/composer"; +import Draft from "discourse/models/draft"; import { acceptance, count, @@ -8,24 +15,11 @@ import { updateCurrentUser, visible, } from "discourse/tests/helpers/qunit-helpers"; -import { - click, - currentURL, - fillIn, - triggerKeyEvent, - visit, -} from "@ember/test-helpers"; -import { skip, test } from "qunit"; -import Draft from "discourse/models/draft"; -import I18n from "I18n"; -import { CREATE_TOPIC, NEW_TOPIC_KEY } from "discourse/models/composer"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import { Promise } from "rsvp"; -import { run } from "@ember/runloop"; import selectKit from "discourse/tests/helpers/select-kit-helper"; +import I18n from "I18n"; +import { skip, test } from "qunit"; +import { Promise } from "rsvp"; import sinon from "sinon"; -import { toggleCheckDraftPopup } from "discourse/controllers/composer"; -import LinkLookup from "discourse/lib/link-lookup"; acceptance("Composer", function (needs) { needs.user(); @@ -808,14 +802,6 @@ acceptance("Composer", function (needs) { ); }); - const assertImageResized = (assert, uploads) => { - assert.strictEqual( - queryAll(".d-editor-input").val(), - uploads.join("\n"), - "it resizes uploaded image" - ); - }; - test("reply button has envelope icon when replying to private message", async function (assert) { await visit("/t/34"); await click("article#post_3 button.reply"); @@ -848,256 +834,6 @@ acceptance("Composer", function (needs) { ); }); - test("Image resizing buttons", async function (assert) { - await visit("/"); - await click("#create-topic"); - - let uploads = [ - // 0 Default markdown with dimensions- should work - "![test|690x313](upload://test.png)", - // 1 Image with scaling percentage, should work - "![test|690x313,50%](upload://test.png)", - // 2 image with scaling percentage and a proceeding whitespace, should work - "![test|690x313, 50%](upload://test.png)", - // 3 No dimensions, should not work - "![test](upload://test.jpeg)", - // 4 Wrapped in backticks should not work - "`![test|690x313](upload://test.png)`", - // 5 html image - should not work - "", - // 6 two images one the same line, but both are syntactically correct - both should work - "![onTheSameLine1|200x200](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)", - // 7 & 8 Identical images - both should work - "![identicalImage|300x300](upload://identicalImage.png)", - "![identicalImage|300x300](upload://identicalImage.png)", - // 9 Image with whitespaces in alt - should work - "![image with spaces in alt|690x220](upload://test.png)", - // 10 Image with markdown title - should work - `![image|690x220](upload://test.png "image title")`, - // 11 bbcode - should not work - "[img]/images/avatar.png[/img]", - // 12 Image with data attributes - "![test|foo=bar|690x313,50%|bar=baz](upload://test.png)", - ]; - - await fillIn(".d-editor-input", uploads.join("\n")); - - assert.strictEqual( - count(".button-wrapper"), - 10, - "it adds correct amount of scaling button groups" - ); - - // Default - uploads[0] = - "![test|690x313, 50%](upload://test.png)"; - await click( - queryAll( - ".button-wrapper[data-image-index='0'] .scale-btn[data-scale='50']" - )[0] - ); - assertImageResized(assert, uploads); - - // Targets the correct image if two on the same line - uploads[6] = - "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250](upload://onTheSameLine2.jpeg)"; - await click( - queryAll( - ".button-wrapper[data-image-index='3'] .scale-btn[data-scale='50']" - )[0] - ); - assertImageResized(assert, uploads); - - // Try the other image on the same line - uploads[6] = - "![onTheSameLine1|200x200, 50%](upload://onTheSameLine1.jpeg) ![onTheSameLine2|250x250, 75%](upload://onTheSameLine2.jpeg)"; - await click( - queryAll( - ".button-wrapper[data-image-index='4'] .scale-btn[data-scale='75']" - )[0] - ); - assertImageResized(assert, uploads); - - // Make sure we target the correct image if there are duplicates - uploads[7] = "![identicalImage|300x300, 50%](upload://identicalImage.png)"; - await click( - queryAll( - ".button-wrapper[data-image-index='5'] .scale-btn[data-scale='50']" - )[0] - ); - assertImageResized(assert, uploads); - - // Try the other dupe - uploads[8] = "![identicalImage|300x300, 75%](upload://identicalImage.png)"; - await click( - queryAll( - ".button-wrapper[data-image-index='6'] .scale-btn[data-scale='75']" - )[0] - ); - assertImageResized(assert, uploads); - - // Don't mess with image titles - uploads[10] = `![image|690x220, 75%](upload://test.png "image title")`; - await click( - queryAll( - ".button-wrapper[data-image-index='8'] .scale-btn[data-scale='75']" - )[0] - ); - assertImageResized(assert, uploads); - - // Keep data attributes - uploads[12] = `![test|foo=bar|690x313, 75%|bar=baz](upload://test.png)`; - await click( - queryAll( - ".button-wrapper[data-image-index='9'] .scale-btn[data-scale='75']" - )[0] - ); - assertImageResized(assert, uploads); - - await fillIn( - ".d-editor-input", - ` -![test|690x313](upload://test.png) - -\`\` - ` - ); - - assert.ok( - !exists("script"), - "it does not unescape script tags in code blocks" - ); - }); - - test("Editing alt text for single image in preview edits alt text in composer", async function (assert) { - const altText = ".image-wrapper .button-wrapper .alt-text"; - const editAltTextButton = - ".image-wrapper .button-wrapper .alt-text-edit-btn"; - const altTextInput = ".image-wrapper .button-wrapper .alt-text-input"; - - await visit("/"); - - await click("#create-topic"); - await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); - - // placement of elements - - assert.ok( - exists(altText), - "shows alt text in the image wrapper's button wrapper" - ); - - assert.ok( - exists(editAltTextButton + " .d-icon-pencil"), - "alt text edit button with icon is in the image wrapper's button wrapper" - ); - - assert.ok( - exists(altTextInput), - "alt text input is in the image wrapper's button wrapper" - ); - - // logical - - assert.equal(query(altText).innerText, "zorro", "correct alt text"); - assert.ok(visible(altText), "alt text is visible"); - assert.ok(visible(editAltTextButton), "alt text edit button is visible"); - assert.ok(invisible(altTextInput), "alt text input is not visible"); - - await click(editAltTextButton); - - assert.ok(invisible(altText), "readonly alt text is not visible"); - assert.ok( - invisible(editAltTextButton), - "alt text edit button is not visible" - ); - assert.ok(visible(altTextInput), "alt text input is visible"); - assert.equal( - queryAll(altTextInput).val(), - "zorro", - "correct alt text in input" - ); - - await triggerKeyEvent(altTextInput, "keypress", "[".charCodeAt(0)); - await triggerKeyEvent(altTextInput, "keypress", "]".charCodeAt(0)); - assert.equal( - queryAll(altTextInput).val(), - "zorro", - "does not input [ ] keys" - ); - - await fillIn(altTextInput, "steak"); - await triggerKeyEvent(altTextInput, "keypress", 13); - - assert.equal( - queryAll(".d-editor-input").val(), - "![steak|200x200](upload://zorro.png)", - "alt text updated" - ); - assert.equal(query(altText).innerText, "steak", "shows the alt text"); - assert.ok(visible(editAltTextButton), "alt text edit button is visible"); - assert.ok(invisible(altTextInput), "alt text input is not visible"); - }); - - test("Editing alt text for one of two images in preview updates correct alt text in composer", async function (assert) { - const editAltTextButton = - ".image-wrapper .button-wrapper .alt-text-edit-btn"; - const altTextInput = ".image-wrapper .button-wrapper .alt-text-input"; - - await visit("/"); - await click("#create-topic"); - - await fillIn( - ".d-editor-input", - `![zorro|200x200](upload://zorro.png) ![not-zorro|200x200](upload://not-zorro.png)` - ); - await click(editAltTextButton); - - await fillIn(altTextInput, "tomtom"); - await triggerKeyEvent(altTextInput, "keypress", 13); - - assert.equal( - queryAll(".d-editor-input").val(), - `![tomtom|200x200](upload://zorro.png) ![not-zorro|200x200](upload://not-zorro.png)`, - "the correct image's alt text updated" - ); - }); - - test("Deleting alt text for image empties alt text in composer and allows further modification", async function (assert) { - const altText = ".image-wrapper .button-wrapper .alt-text"; - const editAltTextButton = - ".image-wrapper .button-wrapper .alt-text-edit-btn"; - const altTextInput = ".image-wrapper .button-wrapper .alt-text-input"; - - await visit("/"); - - await click("#create-topic"); - await fillIn(".d-editor-input", `![zorro|200x200](upload://zorro.png)`); - - await click(editAltTextButton); - - await fillIn(altTextInput, ""); - await triggerKeyEvent(altTextInput, "keypress", 13); - - assert.equal( - queryAll(".d-editor-input").val(), - "![|200x200](upload://zorro.png)", - "alt text updated" - ); - assert.equal(query(altText).innerText, "", "shows the alt text"); - - await click(editAltTextButton); - - await fillIn(altTextInput, "tomtom"); - await triggerKeyEvent(altTextInput, "keypress", 13); - - assert.equal( - queryAll(".d-editor-input").val(), - "![tomtom|200x200](upload://zorro.png)", - "alt text updated" - ); - }); - skip("Shows duplicate_link notice", async function (assert) { await visit("/t/internationalization-localization/280"); await click("#topic-footer-buttons .create"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js index d0a0847c71..09bf1f4ca5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-uploads-uppy-test.js @@ -1,5 +1,6 @@ import { acceptance, + createFile, loggedInUser, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -46,20 +47,10 @@ function pretender(server, helper) { ); } -function createFile(name, type = "image/png") { - // the blob content doesn't matter at all, just want it to be random-ish - const file = new Blob([(Math.random() + 1).toString(36).substring(2)], { - type, - }); - file.name = name; - return file; -} - acceptance("Uppy Composer Attachment - Upload Placeholder", function (needs) { needs.user(); needs.pretender(pretender); needs.settings({ - enable_experimental_composer_uploader: true, simultaneous_uploads: 2, }); @@ -197,7 +188,6 @@ acceptance("Uppy Composer Attachment - Upload Error", function (needs) { }); }); needs.settings({ - enable_experimental_composer_uploader: true, simultaneous_uploads: 2, }); @@ -229,13 +219,16 @@ acceptance("Uppy Composer Attachment - Upload Handler", function (needs) { needs.user(); needs.pretender(pretender); needs.settings({ - enable_experimental_composer_uploader: true, simultaneous_uploads: 2, }); needs.hooks.beforeEach(() => { withPluginApi("0.8.14", (api) => { - api.addComposerUploadHandler(["png"], (file) => { - bootbox.alert(`This is an upload handler test for ${file.name}`); + api.addComposerUploadHandler(["png"], (files) => { + const file = files[0]; + const isNativeFile = file instanceof File ? "WAS" : "WAS NOT"; + bootbox.alert( + `This is an upload handler test for ${file.name}. The file ${isNativeFile} a native file object.` + ); }); }); }); @@ -250,7 +243,7 @@ acceptance("Uppy Composer Attachment - Upload Handler", function (needs) { appEvents.on("composer:uploads-aborted", async () => { assert.strictEqual( queryAll(".bootbox .modal-body").html(), - "This is an upload handler test for handlertest.png", + "This is an upload handler test for handlertest.png. The file WAS a native file object.", "it should show the bootbox triggered by the upload handler" ); await click(".modal-footer .btn"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js index 27d6abfe89..f4e577baab 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js @@ -4,15 +4,12 @@ import { count, exists, fakeTime, - query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; -import { test } from "qunit"; import I18n from "I18n"; +import { test } from "qunit"; acceptance("Invites - Create & Edit Invite Modal", function (needs) { - let deleted; - needs.user(); needs.pretender((server, helper) => { const inviteData = { @@ -42,30 +39,17 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) { }); server.delete("/invites", () => { - deleted = true; return helper.response({}); }); }); - needs.hooks.beforeEach(() => { - deleted = false; - }); test("basic functionality", async function (assert) { await visit("/u/eviltrout/invited/pending"); await click(".user-invite-buttons .btn:first-child"); - assert.strictEqual( - query("input.invite-link").value, - "http://example.com/invites/52641ae8878790bc7b79916247cfe6ba", - "shows an invite link when modal is opened" - ); - await click(".modal-footer .show-advanced"); - await assert.ok(exists(".invite-to-groups"), "shows advanced options"); - await assert.ok(exists(".invite-to-topic"), "shows advanced options"); - await assert.ok(exists(".invite-expires-at"), "shows advanced options"); - - await click(".modal-close"); - assert.ok(deleted, "deletes the invite if not saved"); + await assert.ok(exists(".invite-to-groups")); + await assert.ok(exists(".invite-to-topic")); + await assert.ok(exists(".invite-expires-at")); }); test("saving", async function (assert) { @@ -81,31 +65,14 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) { 1, "adds invite to list after saving" ); - - await click(".modal-close"); - assert.notOk(deleted, "does not delete invite on close"); }); test("copying saves invite", async function (assert) { await visit("/u/eviltrout/invited/pending"); await click(".user-invite-buttons .btn:first-child"); - await click(".invite-link .btn"); - - await click(".modal-close"); - assert.notOk(deleted, "does not delete invite on close"); - }); - - test("copying an email invite without an email shows error message", async function (assert) { - await visit("/u/eviltrout/invited/pending"); - await click(".user-invite-buttons .btn:first-child"); - - await fillIn("#invite-email", "error"); - await click(".invite-link .btn"); - assert.strictEqual( - query("#modal-alert").innerText, - "error isn't a valid email address." - ); + await click(".save-invite"); + assert.ok(exists(".invite-link .btn")); }); }); @@ -159,7 +126,10 @@ acceptance("Invites - Email Invites", function (needs) { groups: [], }; - server.post("/invites", () => helper.response(inviteData)); + server.post("/invites", (request) => { + lastRequest = request; + return helper.response(inviteData); + }); server.put("/invites/1", (request) => { lastRequest = request; @@ -232,7 +202,6 @@ acceptance( await visit("/u/eviltrout/invited/pending"); await click(".user-invite-buttons .btn:first-child"); - await click(".modal-footer .show-advanced"); await click(".future-date-input-selector-header"); const options = Array.from( diff --git a/app/assets/javascripts/discourse/tests/acceptance/dismiss-notification-modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/dismiss-notification-modal-test.js new file mode 100644 index 0000000000..656cba95ae --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/dismiss-notification-modal-test.js @@ -0,0 +1,79 @@ +import { click, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + query, + updateCurrentUser, +} from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { test } from "qunit"; +import pretender from "../helpers/create-pretender"; + +acceptance("Dismiss notification confirmation", function (needs) { + needs.user(); + + test("does not show modal when no high priority notifications", async function (assert) { + pretender.put("/notifications/mark-read", () => { + return [200, { "Content-Type": "application/json" }, { success: true }]; + }); + + await visit("/"); + await click(".current-user"); + await click(".notifications-dismiss"); + assert.notOk(exists(".dismiss-notification-confirmation")); + }); + + test("shows confirmation modal", async function (assert) { + updateCurrentUser({ + unread_high_priority_notifications: 2, + }); + await visit("/"); + await click(".current-user"); + await click(".notifications-dismiss"); + assert.ok(exists(".dismiss-notification-confirmation")); + + assert.strictEqual( + query(".dismiss-notification-confirmation-modal .modal-body").innerText, + I18n.t("notifications.dismiss_confirmation.body", { count: 2 }) + ); + }); + + test("marks unread when confirm and closes modal", async function (assert) { + updateCurrentUser({ + unread_high_priority_notifications: 2, + }); + await visit("/"); + await click(".current-user"); + await click(".notifications-dismiss"); + + assert.strictEqual( + query(".dismiss-notification-confirmation-modal .btn-primary").innerText, + I18n.t("notifications.dismiss_confirmation.dismiss") + ); + pretender.put("/notifications/mark-read", () => { + return [200, { "Content-Type": "application/json" }, { success: true }]; + }); + + await click(".dismiss-notification-confirmation-modal .btn-primary"); + + assert.notOk(exists(".dismiss-notification-confirmation")); + }); + + test("does marks unread when cancel and closes modal", async function (assert) { + updateCurrentUser({ + unread_high_priority_notifications: 2, + }); + await visit("/"); + await click(".current-user"); + await click(".notifications-dismiss"); + + assert.strictEqual( + query(".dismiss-notification-confirmation-modal .btn-default").innerText, + I18n.t("notifications.dismiss_confirmation.cancel") + ); + + await click(".dismiss-notification-confirmation-modal .btn-default"); + + assert.notOk(exists(".dismiss-notification-confirmation")); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/fast-edit-test.js b/app/assets/javascripts/discourse/tests/acceptance/fast-edit-test.js index 3d7b05b887..0485dbd73b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/fast-edit-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/fast-edit-test.js @@ -1,29 +1,36 @@ import { acceptance, exists, - queryAll, + query, selectText, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { test } from "qunit"; +import postFixtures from "discourse/tests/fixtures/post"; +import { cloneJSON } from "discourse-common/lib/object"; acceptance("Fast Edit", function (needs) { needs.user(); needs.settings({ enable_fast_edit: true, }); + needs.pretender((server, helper) => { + server.get("/posts/419", () => { + return helper.response(cloneJSON(postFixtures["/posts/398"])); + }); + }); test("Fast edit button works", async function (assert) { await visit("/t/internationalization-localization/280"); - const textNode = document.querySelector("#post_1 .cooked p").childNodes[0]; + const textNode = query("#post_1 .cooked p").childNodes[0]; await selectText(textNode, 9); await click(".quote-button .quote-edit-label"); assert.ok(exists("#fast-edit-input"), "fast editor is open"); assert.strictEqual( - queryAll("#fast-edit-input").val(), + query("#fast-edit-input").value, "Any plans", "contains selected text" ); @@ -37,14 +44,14 @@ acceptance("Fast Edit", function (needs) { test("Works with keyboard shortcut", async function (assert) { await visit("/t/internationalization-localization/280"); - const textNode = document.querySelector("#post_1 .cooked p").childNodes[0]; + const textNode = query("#post_1 .cooked p").childNodes[0]; await selectText(textNode, 9); await triggerKeyEvent(document, "keypress", "e".charCodeAt(0)); assert.ok(exists("#fast-edit-input"), "fast editor is open"); assert.strictEqual( - queryAll("#fast-edit-input").val(), + query("#fast-edit-input").value, "Any plans", "contains selected text" ); @@ -58,7 +65,7 @@ acceptance("Fast Edit", function (needs) { test("Opens full composer for multi-line selection", async function (assert) { await visit("/t/internationalization-localization/280"); - const textNode = document.querySelector("#post_1 .cooked"); + const textNode = query("#post_2 .cooked"); await selectText(textNode); await click(".quote-button .quote-edit-label"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js index 575685e95e..587fd4473c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js @@ -6,7 +6,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; -import { skip, test } from "qunit"; +import { test } from "qunit"; import userFixtures from "discourse/tests/fixtures/user-fixtures"; import { run } from "@ember/runloop"; @@ -29,9 +29,8 @@ function pressEnter(element, modifier) { acceptance("flagging", function (needs) { needs.user(); needs.pretender((server, helper) => { - const userResponse = Object.assign({}, userFixtures["/u/charlie.json"]); server.get("/u/uwe_keim.json", () => { - return helper.response(userResponse); + return helper.response(userFixtures["/u/charlie.json"]); }); server.get("/admin/users/255.json", () => { return helper.response({ @@ -154,7 +153,7 @@ acceptance("flagging", function (needs) { assert.ok(!exists(".bootbox.modal:visible")); }); - skip("CTRL + ENTER accepts the modal", async function (assert) { + test("CTRL + ENTER accepts the modal", async function (assert) { await visit("/t/internationalization-localization/280"); await openFlagModal(); @@ -170,7 +169,7 @@ acceptance("flagging", function (needs) { assert.ok(!exists("#discourse-modal:visible"), "The modal was closed"); }); - skip("CMD or WINDOWS-KEY + ENTER accepts the modal", async function (assert) { + test("CMD or WINDOWS-KEY + ENTER accepts the modal", async function (assert) { await visit("/t/internationalization-localization/280"); await openFlagModal(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/pending-posts-test.js b/app/assets/javascripts/discourse/tests/acceptance/pending-posts-test.js new file mode 100644 index 0000000000..3644b0eea3 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/pending-posts-test.js @@ -0,0 +1,26 @@ +import { + acceptance, + count, + exists, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { click, visit } from "@ember/test-helpers"; + +acceptance("Pending posts - no existing pending posts", function (needs) { + needs.user(); + + test("No link to pending posts", async function (assert) { + await visit("/u/eviltrout"); + assert.ok(!exists(".action-list [href='/u/eviltrout/activity/pending']")); + }); +}); + +acceptance("Pending posts - existing pending posts", function (needs) { + needs.user({ pending_posts_count: 2 }); + + test("Navigate to pending posts", async function (assert) { + await visit("/u/eviltrout"); + await click("[href='/u/eviltrout/activity/pending']"); + assert.strictEqual(count(".user-stream-item"), 2); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/post-history-test.js b/app/assets/javascripts/discourse/tests/acceptance/post-history-test.js new file mode 100644 index 0000000000..6167e0ff3a --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/post-history-test.js @@ -0,0 +1,63 @@ +import { click, visit } from "@ember/test-helpers"; +import { + acceptance, + count, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; + +acceptance("Post - History", function (needs) { + needs.user(); + + needs.pretender((server, helper) => { + server.get("/posts/419/revisions/latest.json", () => { + return helper.response({ + created_at: "2021-11-24T10:59:36.163Z", + post_id: 419, + previous_hidden: false, + current_hidden: false, + first_revision: 1, + previous_revision: 1, + current_revision: 2, + next_revision: null, + last_revision: 2, + current_version: 2, + version_count: 2, + username: "bianca", + display_username: "bianca", + avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + edit_reason: null, + body_changes: { + inline: '

Welcome to Discourse!', + side_by_side: + '

Welcome to Discourse!

Welcome to Discourse!', + side_by_side_markdown: + '
Welcome to Discourse!Welcome to Discourse!
', + }, + title_changes: { + inline: + '

Welcome to Discourse!
', + side_by_side: + '
Welcome to Discourse!
Welcome to Discourse!
', + }, + user_changes: null, + tags_changes: { + previous: ["tag1", "tag2"], + current: ["tag2", "tag3"], + }, + wiki: false, + can_edit: true, + }); + }); + }); + + test("Shows highlighted tag changes", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("article[data-post-id='419'] .edits button"); + assert.equal(count(".discourse-tag"), 4); + assert.equal(count(".discourse-tag.diff-del"), 1); + assert.equal(query(".discourse-tag.diff-del").textContent, "tag1"); + assert.equal(count(".discourse-tag.diff-ins"), 1); + assert.equal(query(".discourse-tag.diff-ins").textContent, "tag3"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index d3f39aeff8..f5008978de 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -332,6 +332,7 @@ acceptance("Search - Anonymous", function (needs) { acceptance("Search - Authenticated", function (needs) { needs.user(); + needs.settings({ log_search_queries: true }); needs.pretender((server, helper) => { server.get("/search/query", (request) => { @@ -480,6 +481,52 @@ acceptance("Search - Authenticated", function (needs) { `${window.location.origin}${firstLink}`, "hitting A when focused on a search result copies link to composer" ); + + await click("#search-button"); + await triggerKeyEvent("#search-term", "keydown", keyEnter); + + assert.ok( + exists(query(`${container} .search-result-topic`)), + "has topic results" + ); + + await triggerKeyEvent("#search-term", "keydown", keyEnter); + + assert.ok( + exists(query(`.search-container`)), + "second Enter hit goes to full page search" + ); + assert.ok( + !exists(query(`.search-menu`)), + "search dropdown is collapsed after second Enter hit" + ); + + // new search launched, Enter key should be reset + await click("#search-button"); + assert.ok(exists(query(`${container} ul li`)), "has a list of items"); + await triggerKeyEvent("#search-term", "keydown", keyEnter); + assert.ok(exists(query(`.search-menu`)), "search dropdown is visible"); + }); + + test("Shows recent search results", async function (assert) { + await visit("/"); + await click("#search-button"); + + assert.strictEqual( + query( + ".search-menu .search-menu-recent li:nth-of-type(1) .search-link" + ).textContent.trim(), + "yellow", + "shows first recent search" + ); + + assert.strictEqual( + query( + ".search-menu .search-menu-recent li:nth-of-type(2) .search-link" + ).textContent.trim(), + "blue", + "shows second recent search" + ); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js index 0b952d616d..21f6373f5b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js @@ -48,6 +48,23 @@ acceptance("Share and Invite modal", function (needs) { assert.ok(exists("#share-link"), "it shows the share modal"); }); + + test("Share topic in a restricted category", async function (assert) { + await visit("/t/topic-in-restricted-group/2481"); + + assert.ok( + exists("#topic-footer-button-share-and-invite"), + "the button exists" + ); + + await click("#topic-footer-button-share-and-invite"); + + assert.ok(exists(".share-topic-modal"), "it shows the modal"); + assert.ok( + exists("#modal-alert.alert-warning"), + "it shows restricted warning" + ); + }); }); acceptance("Share and Invite modal - mobile", function (needs) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js b/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js new file mode 100644 index 0000000000..042f8232f5 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js @@ -0,0 +1,19 @@ +import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; +import { skip } from "qunit"; +import { visit } from "@ember/test-helpers"; + +acceptance("Sticky Avatars", function () { + skip("Adds sticky avatars when scrolling up", async function (assert) { + const container = document.getElementById("ember-testing-container"); + container.scrollTo(0, 0); + + await visit("/t/internationalization-localization/280"); + container.scrollTo(0, 800); + container.scrollTo(0, 700); + + assert.ok( + query("#post_5").parentElement.classList.contains("sticky-avatar"), + "Sticky avatar is applied" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/tags-test.js b/app/assets/javascripts/discourse/tests/acceptance/tags-test.js index 3f8e95df2c..42dfe9e3d0 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/tags-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/tags-test.js @@ -4,6 +4,7 @@ import { count, exists, invisible, + query, queryAll, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; @@ -64,6 +65,7 @@ acceptance("Tags", function (needs) { bookmarked: false, liked: true, tags: ["test"], + tags_descriptions: { test: "test description" }, views: 42, like_count: 42, has_summary: false, @@ -355,6 +357,7 @@ acceptance("Tag info", function (needs) { tag_info: { id: 13, name: "happy-monkey", + description: "happy monkey description", topic_count: 1, staff: false, synonyms: [], @@ -429,6 +432,28 @@ acceptance("Tag info", function (needs) { ); }); + test("edit tag is showing input for name and description", async function (assert) { + updateCurrentUser({ moderator: false, admin: true }); + + await visit("/tag/happy-monkey"); + assert.strictEqual(count("#show-tag-info"), 1); + + await click("#show-tag-info"); + assert.ok(exists(".tag-info .tag-name"), "show tag"); + + await click("#edit-tag"); + assert.strictEqual( + query("#edit-name").value, + "happy-monkey", + "it displays original tag name" + ); + assert.strictEqual( + query("#edit-description").value, + "happy monkey description", + "it displays original tag description" + ); + }); + test("can filter tags page by category", async function (assert) { await visit("/tag/planters"); @@ -445,7 +470,7 @@ acceptance("Tag info", function (needs) { assert.strictEqual(count("#show-tag-info"), 1); await click("#show-tag-info"); - assert.ok(exists("#rename-tag"), "can rename tag"); + assert.ok(exists("#edit-tag"), "can rename tag"); assert.ok(exists("#edit-synonyms"), "can edit synonyms"); assert.ok(exists("#delete-tag"), "can delete tag"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index 525695f174..cbe8c5c739 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -546,3 +546,28 @@ acceptance("Topic last visit line", function (needs) { ); }); }); + +acceptance("Topic filter replies to post number", function (needs) { + needs.settings({ + enable_filtered_replies_view: true, + }); + + test("visit topic", async function (assert) { + await visit("/t/-/280"); + + assert.equal( + query("#post_3 .show-replies").title, + I18n.t("post.filtered_replies_hint", { count: 3 }), + "it displays the right title for filtering by replies" + ); + + await visit("/"); + await visit("/t/-/280?replies_to_post_number=3"); + + assert.equal( + query("#post_3 .show-replies").title, + I18n.t("post.view_all_posts"), + "it displays the right title when filtered by replies" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-activity-read-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-activity-read-test.js index 7c85564268..a5bd8d42c8 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-activity-read-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-activity-read-test.js @@ -1,6 +1,37 @@ -import { acceptance, exists } from "../helpers/qunit-helpers"; +import { acceptance, exists, queryAll } from "../helpers/qunit-helpers"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; +import userFixtures from "../fixtures/user-fixtures"; + +acceptance("User Activity / Read - bulk actions", function (needs) { + needs.user(); + + needs.pretender((server, helper) => { + server.get("/read.json", () => { + return helper.response(userFixtures["/topics/created-by/eviltrout.json"]); + }); + + server.put("topics/bulk", () => { + return helper.response({ topic_ids: [7764, 9318] }); + }); + }); + + test("bulk topic closing works", async function (assert) { + await visit("/u/charlie/activity/read"); + + await click("button.bulk-select"); + await click(queryAll("input.bulk-select")[0]); + await click(queryAll("input.bulk-select")[1]); + await click("button.bulk-select-actions"); + + await click("div.bulk-buttons button:nth-child(2)"); // the Close Topics button + + assert.notOk( + exists("div.bulk-buttons"), + "The bulk actions modal was closed" + ); + }); +}); acceptance("User Activity / Read - empty state", function (needs) { needs.user(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-activity-topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-activity-topic-test.js index da16e75148..e669d1e20b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-activity-topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-activity-topic-test.js @@ -1,6 +1,37 @@ -import { acceptance, exists } from "../helpers/qunit-helpers"; +import { acceptance, exists, queryAll } from "../helpers/qunit-helpers"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; +import userFixtures from "../fixtures/user-fixtures"; + +acceptance("User Activity / Topics - bulk actions", function (needs) { + needs.user(); + + needs.pretender((server, helper) => { + server.get("/topics/created-by/:username.json", () => { + return helper.response(userFixtures["/topics/created-by/eviltrout.json"]); + }); + + server.put("/topics/bulk", () => { + return helper.response({ topic_ids: [7764, 9318] }); + }); + }); + + test("bulk topic closing works", async function (assert) { + await visit("/u/charlie/activity/topics"); + + await click("button.bulk-select"); + await click(queryAll("input.bulk-select")[0]); + await click(queryAll("input.bulk-select")[1]); + await click("button.bulk-select-actions"); + + await click("div.bulk-buttons button:nth-child(2)"); // the Close Topics button + + assert.notOk( + exists("div.bulk-buttons"), + "The bulk actions modal was closed" + ); + }); +}); acceptance("User Activity / Topics - empty state", function (needs) { needs.user(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-card-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-card-test.js index 2e9281f368..4b4a0c1aa3 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-card-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-card-test.js @@ -3,12 +3,13 @@ import { click, visit } from "@ember/test-helpers"; import User from "discourse/models/user"; import { test } from "qunit"; import userFixtures from "discourse/tests/fixtures/user-fixtures"; +import { cloneJSON } from "discourse-common/lib/object"; acceptance("User Card - Show Local Time", function (needs) { needs.user(); needs.settings({ display_local_time_in_user_card: true }); needs.pretender((server, helper) => { - let cardResponse = Object.assign({}, userFixtures["/u/charlie/card.json"]); + const cardResponse = cloneJSON(userFixtures["/u/charlie/card.json"]); delete cardResponse.user.timezone; server.get("/u/charlie/card.json", () => helper.response(cardResponse)); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js index 5445eceec9..1b0fa65e58 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js @@ -16,7 +16,7 @@ acceptance("User Drafts", function (needs) { await visit("/u/eviltrout/activity/drafts"); assert.strictEqual(count(".user-stream-item"), 3, "has drafts"); - await click(".user-stream-item:last-child .remove-draft"); + await click(".user-stream-item:first-child .remove-draft"); assert.ok(visible(".bootbox")); await click(".bootbox .btn-primary"); @@ -25,6 +25,13 @@ acceptance("User Drafts", function (needs) { 2, "draft removed, list length diminished by one" ); + + await visit("/"); + assert.ok(visible("#create-topic")); + assert.ok( + !exists("#create-topic.open-draft"), + "Open Draft button is not present" + ); }); test("Stream - resume draft", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/fixtures/category-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/category-fixtures.js index 6edf9ea190..44ed93de9f 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/category-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/category-fixtures.js @@ -48,5 +48,18 @@ export default { slug: "testing", can_edit: true } - } + }, + "/c/2481/show.json": { + category: { + id: 2481, + name: "restricted-group", + color: "e9dd00", + text_color: "000000", + slug: "restricted-group", + read_restricted: true, + permission: null, + group_permissions: [{ permission_type: 1, group_name: "moderators" }], + } + }, + }; diff --git a/app/assets/javascripts/discourse/tests/fixtures/discovery-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/discovery-fixtures.js index c981d7e364..65f3c515e0 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/discovery-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/discovery-fixtures.js @@ -6405,6 +6405,7 @@ export default { bookmarked: false, liked: false, tags: ["test", "test-tag"], + tags_description: { test: "test description", "test-tag": "test tag description" }, views: 6, like_count: 0, has_summary: false, diff --git a/app/assets/javascripts/discourse/tests/fixtures/pending-posts.js b/app/assets/javascripts/discourse/tests/fixtures/pending-posts.js new file mode 100644 index 0000000000..bd14bace7c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/fixtures/pending-posts.js @@ -0,0 +1,32 @@ +export default { + "/posts/eviltrout/pending.json": { + pending_posts: [ + { + id: 2, + avatar_template: "/user_avatar/localhost/eviltrout/{size}/5275.png", + category_id: 2, + created_at: "2021-10-19T10:18:13.238Z", + created_by_id: 19, + name: "Robin Ward", + raw_text: "**bold text**", + title: "Lorem ipsum dolor sit amet", + topic_id: 130, + topic_url: "/t/lorem-ipsum-dolor-sit-amet/130", + username: "eviltrout" + }, + { + id: 1, + avatar_template: "/user_avatar/localhost/eviltrout/{size}/5275.png", + category_id: 2, + created_at: "2021-10-19T09:38:35.110Z", + created_by_id: 19, + name: "Robin Ward", + raw_text: "This will be moderated in theory :thinking:", + title: "Lorem ipsum dolor sit amet", + topic_id: 130, + topic_url: "/t/lorem-ipsum-dolor-sit-amet/130", + username: "eviltrout" + }, + ] + } +}; diff --git a/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js index 84e7424046..bfb8a44999 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js @@ -540,6 +540,10 @@ export default { pinned_globally: false, posters: [], tags: ["dev", "slow"], + tags_descriptions: { + "dev": "dev description", + "slow": "slow description", + } }, { id: 14727, diff --git a/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js index 68488ba5f9..1151e2e20a 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js @@ -438,6 +438,23 @@ export default { default_view: "latest", subcategory_list_style: "boxes", }, + { + id: 2481, + name: "Restricted Group", + color: "0E78BD", + text_color: "FFFFFF", + slug: "restricted-group", + topic_count: 137, + post_count: 1142, + description: "A restricted group", + topic_url: "/t/category-definition-for-restricted-group/11", + read_restricted: true, + permission: 1, + notification_level: null, + show_subcategory_list: true, + default_view: "latest", + subcategory_list_style: "boxes", + }, ], post_action_types: [ { diff --git a/app/assets/javascripts/discourse/tests/fixtures/topic.js b/app/assets/javascripts/discourse/tests/fixtures/topic.js index a4db6fe497..35437dbca9 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/topic.js +++ b/app/assets/javascripts/discourse/tests/fixtures/topic.js @@ -5855,4 +5855,347 @@ export default { }, }, }, + "/t/2481/1.json": { + post_stream: { + posts: [ + { + id: 2441, + name: "", + username: "group_moderator", + avatar_template: "/images/avatar.png", + created_at: "2020-07-24T17:48:55.419Z", + cooked: + "

Here is my new topic. I am a group category moderator!

", + post_number: 1, + post_type: 1, + updated_at: "2020-07-24T17:48:55.419Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 2, + readers_count: 1, + score: 0, + yours: true, + topic_id: 2481, + topic_slug: "topic-in-restricted-group", + display_username: "", + primary_group_name: "group_moderators", + flair_name: "group_moderators", + flair_url: "cheese", + flair_bg_color: "ff0", + flair_color: "", + version: 1, + can_edit: true, + can_delete: false, + can_recover: false, + can_wiki: false, + read: true, + user_title: "a title", + title_is_group: false, + bookmarked: false, + bookmarks: [], + actions_summary: [ + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: false, + staff: false, + group_moderator: true, + user_id: 3, + hidden: false, + trust_level: 1, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + }, + { + id: 2442, + name: "", + username: "normal_user", + avatar_template: "/images/avatar.png", + created_at: "2020-07-24T17:50:01.263Z", + cooked: "

A fascinating topic worthy of discussion.

", + post_number: 2, + post_type: 1, + updated_at: "2020-07-24T17:50:01.263Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 2, + readers_count: 1, + score: 0, + yours: false, + topic_id: 2481, + topic_slug: "topic-in-restricted-group", + display_username: "", + primary_group_name: null, + flair_name: null, + flair_url: null, + flair_bg_color: null, + flair_color: null, + version: 1, + can_edit: false, + can_delete: false, + can_recover: false, + can_wiki: false, + read: true, + user_title: null, + bookmarked: false, + bookmarks: [], + actions_summary: [ + { + id: 2, + can_act: true, + }, + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 6, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: false, + staff: false, + user_id: 2, + hidden: false, + trust_level: 1, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + }, + { + id: 2443, + name: "", + username: "group_moderator", + avatar_template: "/images/avatar.png", + created_at: "2020-07-24T17:50:17.274Z", + cooked: '', + post_number: 3, + post_type: 1, + updated_at: "2020-07-24T17:50:17.274Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 2, + readers_count: 1, + score: 0, + yours: true, + topic_id: 2481, + topic_slug: "topic-in-restricted-group", + display_username: "", + primary_group_name: "group_moderators", + flair_url: "cheese", + flair_bg_color: "ff0", + flair_color: "", + version: 1, + can_edit: true, + can_delete: true, + can_recover: false, + can_wiki: false, + read: true, + user_title: "a title", + title_is_group: false, + bookmarked: false, + bookmarks: [], + actions_summary: [ + { + id: 3, + can_act: true, + }, + { + id: 4, + can_act: true, + }, + { + id: 8, + can_act: true, + }, + { + id: 7, + can_act: true, + }, + ], + moderator: false, + admin: false, + staff: false, + group_moderator: true, + user_id: 3, + hidden: false, + trust_level: 1, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + }, + ], + stream: [2441, 2442, 2443], + }, + timeline_lookup: [[1, 0]], + id: 2481, + title: "A Topic in a Restricted Group", + fancy_title: "A Topic in a Restricted Group", + posts_count: 3, + created_at: "2020-07-24T17:48:54.986Z", + views: 2, + reply_count: 0, + like_count: 0, + last_posted_at: "2020-07-24T17:50:17.274Z", + visible: true, + closed: false, + archived: false, + has_summary: false, + archetype: "regular", + slug: "topic-in-restricted-group", + category_id: 2481, + word_count: 22, + deleted_at: null, + user_id: 3, + featured_link: null, + pinned_globally: false, + pinned_at: null, + pinned_until: null, + image_url: null, + draft: null, + draft_key: "topic_2481", + draft_sequence: 1, + posted: true, + unpinned: null, + pinned: false, + current_post_number: 3, + highest_post_number: 3, + last_read_post_number: 3, + last_read_post_id: 43, + deleted_by: null, + actions_summary: [ + { + id: 4, + count: 0, + hidden: false, + can_act: true, + }, + { + id: 8, + count: 0, + hidden: false, + can_act: true, + }, + { + id: 7, + count: 0, + hidden: false, + can_act: true, + }, + ], + chunk_size: 20, + bookmarked: false, + bookmarks: [], + topic_timer: null, + message_bus_last_id: 4, + participant_count: 2, + show_read_indicator: false, + thumbnails: null, + details: { + notification_level: 3, + notifications_reason_id: 1, + can_delete: true, + can_edit: true, + can_create_post: true, + can_move_posts: true, + can_reply_as_new_topic: true, + can_flag_topic: true, + can_review_topic: true, + can_close_topic: true, + can_archive_topic: true, + can_toggle_topic_visibility: true, + can_split_merge_topic: true, + can_edit_staff_notes: true, + can_pin_unpin_topic: true, + can_moderate_category: true, + participants: [ + { + id: 3, + username: "group_moderator", + name: "", + avatar_template: "/images/avatar.png", + post_count: 2, + primary_group_name: "group_moderators", + flar_name: "group_moderators", + flair_url: "cheese", + flair_color: "", + flair_bg_color: "ff0", + }, + { + id: 2, + username: "normal_user", + name: "", + avatar_template: "/images/avatar.png", + post_count: 1, + primary_group_name: null, + flair_name: null, + flair_url: null, + flair_color: null, + flair_bg_color: null, + }, + ], + created_by: { + id: 3, + username: "group_moderator", + name: "", + avatar_template: "/images/avatar.png", + }, + last_poster: { + id: 3, + username: "group_moderator", + name: "", + avatar_template: "/images/avatar.png", + }, + }, + }, }; diff --git a/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js index 43cde04348..0883ca4a6f 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/user-fixtures.js @@ -295,6 +295,7 @@ export default { day_6_end_time: 1020, }, timezone: "Australia/Brisbane", + has_topic_draft: true }, }, "/u/eviltrout/card.json": { @@ -3473,4 +3474,11 @@ export default { timezone: "Australia/Brisbane", }, }, + "/u/recent-searches": { + success: "OK", + recent_searches: [ + "yellow", + "blue" + ] + } }; diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index a81481281e..9301d6ddc2 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -281,6 +281,9 @@ export function applyDefaultHandlers(pretender) { pretender.get("/t/2480.json", () => response(fixturesByUrl["/t/2480/1.json"]) ); + pretender.get("/t/2481.json", () => + response(fixturesByUrl["/t/2481/1.json"]) + ); pretender.get("/t/id_for/:slug", () => { return response({ @@ -344,6 +347,10 @@ export function applyDefaultHandlers(pretender) { response(fixturesByUrl["/c/1/show.json"]) ); + pretender.get("/c/restricted-group/find_by_slug.json", () => + response(fixturesByUrl["/c/2481/show.json"]) + ); + pretender.put("/categories/:category_id", (request) => { const category = parsePostData(request.requestBody); category.id = parseInt(request.params.category_id, 10); diff --git a/app/assets/javascripts/discourse/tests/helpers/create-store.js b/app/assets/javascripts/discourse/tests/helpers/create-store.js index ad15ffd4fa..93a8520d6f 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-store.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-store.js @@ -1,6 +1,6 @@ import KeyValueStore from "discourse/lib/key-value-store"; import RestAdapter from "discourse/adapters/rest"; -import Store from "discourse/models/store"; +import Store from "discourse/services/store"; import TopicListAdapter from "discourse/adapters/topic-list"; import TopicTrackingState from "discourse/models/topic-tracking-state"; import { buildResolver } from "discourse-common/resolver"; diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 439751b7e2..ed745f5dee 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -21,7 +21,7 @@ import { _clearSnapshots } from "select-kit/components/composer-actions"; import { clearHTMLCache } from "discourse/helpers/custom-html"; import createStore from "discourse/tests/helpers/create-store"; import deprecated from "discourse-common/lib/deprecated"; -import { flushMap } from "discourse/models/store"; +import { flushMap } from "discourse/services/store"; import { initSearchData } from "discourse/widgets/search-menu"; import { resetPostMenuExtraButtons } from "discourse/widgets/post-menu"; import { isEmpty } from "@ember/utils"; @@ -48,12 +48,15 @@ import { cleanUpComposerUploadHandler, cleanUpComposerUploadMarkdownResolver, cleanUpComposerUploadPreProcessor, - cleanUpComposerUploadProcessor, } from "discourse/components/composer-editor"; import { resetLastEditNotificationClick } from "discourse/models/post-stream"; import { clearAuthMethods } from "discourse/models/login-method"; import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown"; import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button"; +import { + clearPresenceCallbacks, + setTestPresence, +} from "discourse/lib/user-presence"; const LEGACY_ENV = !setupApplicationTest; @@ -201,7 +204,7 @@ export function acceptance(name, optionsOrCallback) { } else if (typeof optionsOrCallback === "object") { deprecated( `${name}: The second parameter to \`acceptance\` should be a function that encloses your tests.`, - { since: "2.6.0" } + { since: "2.6.0", dropFrom: "2.9.0.beta1" } ); options = optionsOrCallback; } @@ -290,13 +293,16 @@ export function acceptance(name, optionsOrCallback) { setTopicList(null); _clearSnapshots(); cleanUpComposerUploadHandler(); - cleanUpComposerUploadProcessor(); cleanUpComposerUploadMarkdownResolver(); cleanUpComposerUploadPreProcessor(); clearTopicFooterDropdowns(); clearTopicFooterButtons(); resetLastEditNotificationClick(); clearAuthMethods(); + setTestPresence(true); + if (!LEGACY_ENV) { + clearPresenceCallbacks(); + } app._runInitializer("instanceInitializers", (_, initializer) => { initializer.teardown?.(); @@ -541,3 +547,14 @@ export function chromeTest(name, testCase) { export function firefoxTest(name, testCase) { conditionalTest(name, navigator.userAgent.includes("Firefox"), testCase); } + +export function createFile(name, type = "image/png", blobData = null) { + // the blob content doesn't matter at all, just want it to be random-ish + blobData = blobData || (Math.random() + 1).toString(36).substring(2); + const blob = new Blob([blobData]); + const file = new File([blob], name, { + type, + lastModified: new Date().getTime(), + }); + return file; +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js index aa37f57817..aa0808ed86 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js @@ -729,10 +729,11 @@ third line` }); async function paste(element, text) { - let e = new Event("paste"); + let e = new Event("paste", { cancelable: true }); e.clipboardData = { getData: () => text }; element.dispatchEvent(e); await settled(); + return e; } componentTest("paste table", { @@ -763,6 +764,75 @@ third line` }, }); + testCase( + `pasting a link into a selection applies a link format`, + async function (assert, textarea) { + this.set("value", "See discourse in action"); + setTextareaSelection(textarea, 4, 13); + const element = query(".d-editor"); + const event = await paste(element, "https://www.discourse.org/"); + assert.strictEqual( + this.value, + "See [discourse](https://www.discourse.org/) in action" + ); + assert.strictEqual(event.defaultPrevented, true); + } + ); + + testCase( + `pasting other text into a selection will replace text value`, + async function (assert, textarea) { + this.set("value", "good morning"); + setTextareaSelection(textarea, 5, 12); + const element = query(".d-editor"); + const event = await paste(element, "evening"); + // Synthetic paste events do not manipulate document content. + assert.strictEqual(this.value, "good morning"); + assert.strictEqual(event.defaultPrevented, false); + } + ); + + testCase( + `pasting a url without a selection will insert the url`, + async function (assert, textarea) { + this.set("value", "a link example:"); + jumpEnd(textarea); + const element = query(".d-editor"); + const event = await paste(element, "https://www.discourse.org/"); + // Synthetic paste events do not manipulate document content. + assert.strictEqual(this.value, "a link example:"); + assert.strictEqual(event.defaultPrevented, false); + } + ); + + testCase( + `pasting text that contains urls and other content will use default paste behavior`, + async function (assert, textarea) { + this.set("value", "a link example:"); + setTextareaSelection(textarea, 0, 1); + const element = query(".d-editor"); + const event = await paste( + element, + "Try out Discourse at: https://www.discourse.org/" + ); + // Synthetic paste events do not manipulate document content. + assert.strictEqual(this.value, "a link example:"); + assert.strictEqual(event.defaultPrevented, false); + } + ); + + testCase( + `pasting an email into a selection applies a link format`, + async function (assert, textarea) { + this.set("value", "team email"); + setTextareaSelection(textarea, 5, 10); + const element = query(".d-editor"); + const event = await paste(element, "mailto:team@discourse.org"); + assert.strictEqual(this.value, "team [email](mailto:team@discourse.org)"); + assert.strictEqual(event.defaultPrevented, true); + } + ); + (() => { // Tests to check cursor/selection after replace-text event. const BEFORE = "red green blue"; diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-icon-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-icon-test.js index cb3a2bdd77..38e79c121f 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-icon-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-icon-test.js @@ -17,7 +17,7 @@ discourseModule("Integration | Component | d-icon", function (hooks) { const html = queryAll(".test").html().trim(); assert.strictEqual( html, - '' + '' ); }, }); @@ -29,7 +29,7 @@ discourseModule("Integration | Component | d-icon", function (hooks) { const html = queryAll(".test").html().trim(); assert.strictEqual( html, - '' + '' ); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/empty-state-test.js b/app/assets/javascripts/discourse/tests/integration/components/empty-state-test.js new file mode 100644 index 0000000000..3c504caf2b --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/empty-state-test.js @@ -0,0 +1,18 @@ +import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; + +discourseModule("Integration | Component | empty-state", function (hooks) { + setupRenderingTest(hooks); + + componentTest("it renders", { + template: hbs``, + + test(assert) { + assert.strictEqual(query("[data-test-title]").textContent, "title"); + assert.strictEqual(query("[data-test-body]").textContent, "body"); + }, + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/pending-post-test.js b/app/assets/javascripts/discourse/tests/integration/components/pending-post-test.js new file mode 100644 index 0000000000..85be0e9dff --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/pending-post-test.js @@ -0,0 +1,35 @@ +import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import createStore from "discourse/tests/helpers/create-store"; + +discourseModule("Integration | Component | pending-post", function (hooks) { + setupRenderingTest(hooks); + + componentTest("it renders", { + template: hbs``, + + beforeEach() { + const store = createStore(); + store.createRecord("category", { id: 2 }); + const post = store.createRecord("pending-post", { + id: 1, + topic_url: "topic-url", + username: "USERNAME", + category_id: 2, + raw_text: "**bold text**", + }); + this.set("post", post); + }, + + test(assert) { + assert.strictEqual( + query("p.excerpt").textContent.trim(), + "bold text", + "renders the cooked text" + ); + }, + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/category-chooser-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/category-chooser-test.js index 6840f93ecb..2fc4bd4095 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/category-chooser-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/category-chooser-test.js @@ -107,7 +107,7 @@ discourseModule( assert.strictEqual( this.subject.rows().length, - 21, + 22, "all categories are visible" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/multi-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/multi-select-test.js index 8352018857..131a5f65a4 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/multi-select-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/multi-select-test.js @@ -67,3 +67,75 @@ discourseModule( }); } ); + +discourseModule( + "Integration | Component | select-kit/multi-select | maximum=1", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + }); + + componentTest("content", { + template: hbs` + {{multi-select + value=value + content=content + options=(hash maximum=1) + }} + `, + + beforeEach() { + setDefaultState(this); + }, + + async test(assert) { + await this.subject.expand(); + await this.subject.selectRowByValue(1); + + assert.notOk(this.subject.isExpanded(), "it closes the dropdown"); + + await this.subject.expand(); + await this.subject.deselectItemByValue(1); + + assert.ok( + this.subject.isExpanded(), + "it doesn’t close the dropdown when no selection has been made" + ); + }, + }); + } +); + +discourseModule( + "Integration | Component | select-kit/multi-select | maximum=2", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set("subject", selectKit()); + }); + + componentTest("content", { + template: hbs` + {{multi-select + value=value + content=content + options=(hash maximum=2) + }} + `, + + beforeEach() { + setDefaultState(this); + }, + + async test(assert) { + await this.subject.expand(); + await this.subject.selectRowByValue(1); + + assert.ok(this.subject.isExpanded(), "it doesn’t close the dropdown"); + }, + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js index 1458fa9cf7..3cd9961d0d 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js @@ -7,18 +7,23 @@ import { exists, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; +import { resetPostMenuExtraButtons } from "discourse/widgets/post-menu"; import { withPluginApi } from "discourse/lib/plugin-api"; discourseModule( "Integration | Component | Widget | post-menu", function (hooks) { + hooks.afterEach(() => { + resetPostMenuExtraButtons(); + }); + setupRenderingTest(hooks); componentTest("add extra button", { template: hbs`{{mount-widget widget="post-menu" args=args}}`, beforeEach() { this.set("args", {}); - withPluginApi("0.8", (api) => { + withPluginApi("0.14.0", (api) => { api.addPostMenuButton("coffee", () => { return { action: "drinkCoffee", @@ -30,6 +35,7 @@ discourseModule( }); }); }, + async test(assert) { assert.strictEqual( count(".actions .extra-buttons .hot-coffee"), @@ -39,19 +45,54 @@ discourseModule( }, }); - componentTest("remove extra button", { + componentTest("removes button based on callback", { template: hbs`{{mount-widget widget="post-menu" args=args}}`, + beforeEach() { - this.set("args", {}); - withPluginApi("0.8", (api) => { - api.removePostMenuButton("coffee"); + this.set("args", { canCreatePost: true, canRemoveReply: true }); + + withPluginApi("0.14.0", (api) => { + api.removePostMenuButton("reply", (attrs) => { + return attrs.canRemoveReply; + }); }); }, + async test(assert) { - assert.ok( - !exists(".actions .extra-buttons .hot-coffee"), - "It doesn't removes coffee button" - ); + assert.ok(!exists(".actions .reply"), "it removes reply button"); + }, + }); + + componentTest("does not remove butto", { + template: hbs`{{mount-widget widget="post-menu" args=args}}`, + + beforeEach() { + this.set("args", { canCreatePost: true, canRemoveReply: false }); + + withPluginApi("0.14.0", (api) => { + api.removePostMenuButton("reply", (attrs) => { + return attrs.canRemoveReply; + }); + }); + }, + + async test(assert) { + assert.ok(exists(".actions .reply"), "it does not remove reply button"); + }, + }); + + componentTest("removes button", { + template: hbs`{{mount-widget widget="post-menu" args=args}}`, + beforeEach() { + this.set("args", { canCreatePost: true }); + + withPluginApi("0.14.0", (api) => { + api.removePostMenuButton("reply"); + }); + }, + + async test(assert) { + assert.ok(!exists(".actions .reply"), "it removes reply button"); }, }); } diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index ec7f80b578..7091b47143 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -27,7 +27,7 @@ import bootbox from "bootbox"; import { buildResolver } from "discourse-common/resolver"; import { createHelperContext } from "discourse-common/lib/helpers"; import deprecated from "discourse-common/lib/deprecated"; -import { flushMap } from "discourse/models/store"; +import { flushMap } from "discourse/services/store"; import { registerObjects } from "discourse/pre-initializers/inject-discourse-objects"; import sinon from "sinon"; import { run } from "@ember/runloop"; diff --git a/app/assets/javascripts/discourse/tests/test-helper.js b/app/assets/javascripts/discourse/tests/test-helper.js index 2fd001c627..e28c99c548 100644 --- a/app/assets/javascripts/discourse/tests/test-helper.js +++ b/app/assets/javascripts/discourse/tests/test-helper.js @@ -2,6 +2,8 @@ import config from "../config/environment"; import { setEnvironment } from "discourse-common/config/environment"; import { start } from "ember-qunit"; import loadEmberExam from "ember-exam/test-support/load"; +import * as QUnit from "qunit"; +import { setup } from "qunit-dom"; setEnvironment("testing"); @@ -20,6 +22,7 @@ document.addEventListener("discourse-booted", () => { ` ); + setup(QUnit.assert); setupTests(config.APP); let loader = loadEmberExam(); loader.loadModules(); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js index a361bf19e7..7d49a9c7f9 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js @@ -3,8 +3,8 @@ import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; discourseModule("Unit | Controller | create-account", function () { - test("basicUsernameValidation", async function (assert) { - const testInvalidUsername = async (username, expectedReason) => { + test("basicUsernameValidation", function (assert) { + const testInvalidUsername = (username, expectedReason) => { const controller = this.getController("create-account"); controller.set("accountUsername", username); @@ -24,8 +24,7 @@ discourseModule("Unit | Controller | create-account", function () { I18n.t("user.username.too_long") ); - const controller = await this.owner.lookup("controller:create-account"); - controller.setProperties({ + const controller = this.getController("create-account", { accountUsername: "porkchops", prefilledUsername: "porkchops", }); @@ -39,7 +38,7 @@ discourseModule("Unit | Controller | create-account", function () { ); }); - test("passwordValidation", async function (assert) { + test("passwordValidation", function (assert) { const controller = this.getController("create-account"); controller.set("authProvider", ""); @@ -49,12 +48,12 @@ discourseModule("Unit | Controller | create-account", function () { controller.set("accountPassword", "b4fcdae11f9167"); assert.strictEqual( - controller.get("passwordValidation.ok"), + controller.passwordValidation.ok, true, "Password is ok" ); assert.strictEqual( - controller.get("passwordValidation.reason"), + controller.passwordValidation.reason, I18n.t("user.password.ok"), "Password is valid" ); @@ -63,12 +62,12 @@ discourseModule("Unit | Controller | create-account", function () { controller.set("accountPassword", password); assert.strictEqual( - controller.get("passwordValidation.failed"), + controller.passwordValidation.failed, true, "password should be invalid: " + password ); assert.strictEqual( - controller.get("passwordValidation.reason"), + controller.passwordValidation.reason, expectedReason, "password validation reason: " + password + ", " + expectedReason ); @@ -83,8 +82,8 @@ discourseModule("Unit | Controller | create-account", function () { ); }); - test("authProviderDisplayName", async function (assert) { - const controller = this.owner.lookup("controller:create-account"); + test("authProviderDisplayName", function (assert) { + const controller = this.getController("create-account"); assert.strictEqual( controller.authProviderDisplayName("facebook"), diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/preferences-profile-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/preferences-profile-test.js index b301d71790..ff1caaca0c 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/preferences-profile-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/preferences-profile-test.js @@ -1,7 +1,7 @@ import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; -import EmberObject from "@ember/object"; import User from "discourse/models/user"; +import Site from "discourse/models/site"; discourseModule("Unit | Controller | preferences/profile", function () { test("prepare custom field data", function (assert) { @@ -11,35 +11,30 @@ discourseModule("Unit | Controller | preferences/profile", function () { second_factor_enabled: true, is_anonymous: true, user_fields: { - field_1: "1", - field_2: "2", - field_3: "3", + 1: "2", + 2: null, + 3: [], }, }), currentUser: { id: 1234, }, }); - controller.set("userFields", [ - EmberObject.create({ value: "2", field: { id: "field_1" } }), - EmberObject.create({ value: null, field: { id: "field_2" } }), - EmberObject.create({ value: [], field: { id: "field_3" } }), + + Site.currentProp("user_fields", [ + { position: 1, id: 1, editable: true }, + { position: 2, id: 2, editable: true }, + { position: 3, id: 3, editable: true }, ]); + + // Since there are no injections in unit tests + controller.set("site", Site.current()); + controller.send("_updateUserFields"); - assert.strictEqual( - controller.model.user_fields.field_1, - "2", - "updates string value" - ); - assert.strictEqual( - controller.model.user_fields.field_2, - null, - "updates null" - ); - assert.strictEqual( - controller.model.user_fields.field_3, - null, - "updates empty array as null" - ); + + const fields = controller.model.user_fields; + assert.strictEqual(fields[1], "2", "updates string value"); + assert.strictEqual(fields[2], null, "updates null"); + assert.strictEqual(fields[3], null, "updates empty array as null"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js index 7da6519769..bd8badbf58 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/topic-test.js @@ -17,11 +17,10 @@ function topicWithStream(streamDetails) { discourseModule("Unit | Controller | topic", function (hooks) { hooks.beforeEach(function () { - this.registry.register("service:screen-track", {}, { instantiate: false }); this.registry.injection("controller", "appEvents", "service:app-events"); }); + hooks.afterEach(function () { - this.registry.unregister("service:screen-track"); this.registry.unregister("current-user:main"); let topic = this.container.lookup("controller:topic"); topic.setProperties({ diff --git a/app/assets/javascripts/discourse/tests/unit/lib/icon-library-test.js b/app/assets/javascripts/discourse/tests/unit/lib/icon-library-test.js index 88a3dda459..b2b0a99a99 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/icon-library-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/icon-library-test.js @@ -7,7 +7,7 @@ import { module, test } from "qunit"; module("Unit | Utility | icon-library", function () { test("return icon markup", function (assert) { - assert.ok(iconHTML("bars").indexOf('use xlink:href="#bars"') > -1); + assert.ok(iconHTML("bars").indexOf('use href="#bars"') > -1); const nodeIcon = iconNode("bars"); assert.strictEqual(nodeIcon.tagName, "svg"); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/key-value-store-test.js b/app/assets/javascripts/discourse/tests/unit/lib/key-value-store-test.js index 243dc9a981..ed323d85b9 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/key-value-store-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/key-value-store-test.js @@ -2,18 +2,37 @@ import { module, test } from "qunit"; import KeyValueStore from "discourse/lib/key-value-store"; module("Unit | Utility | key-value-store", function () { - test("it's able to get the result back from the store", function (assert) { - const store = new KeyValueStore("_test"); + test("is able to get the result back from the store", function (assert) { + const store = new KeyValueStore("test_"); store.set({ key: "bob", value: "uncle" }); + assert.strictEqual(store.get("bob"), "uncle"); }); + test("is able remove items from the store", function (assert) { + const store = new KeyValueStore("test_"); + store.set({ key: "bob", value: "uncle" }); + store.remove("bob"); + + assert.strictEqual(store.get("bob"), undefined); + }); + test("is able to nuke the store", function (assert) { - const store = new KeyValueStore("_test"); + const store = new KeyValueStore("test_"); store.set({ key: "bob1", value: "uncle" }); store.abandonLocal(); localStorage.a = 1; - assert.strictEqual(store.get("bob1"), void 0); + + assert.strictEqual(store.get("bob1"), undefined); assert.strictEqual(localStorage.a, "1"); }); + + test("is API-compatible with `localStorage`", function (assert) { + const store = new KeyValueStore("test_"); + store.setItem("bob", "uncle"); + assert.strictEqual(store.getItem("bob"), "uncle"); + + store.removeItem("bob"); + assert.strictEqual(store.getItem("bob"), undefined); + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/suffix-trie-test.js b/app/assets/javascripts/discourse/tests/unit/lib/suffix-trie-test.js new file mode 100644 index 0000000000..363c287db2 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/suffix-trie-test.js @@ -0,0 +1,30 @@ +import { module, test } from "qunit"; +import SuffixTrie from "discourse-common/lib/suffix-trie"; + +module("Unit | SuffixTrie", function () { + test("SuffixTrie", function (assert) { + const t = new SuffixTrie("/"); + t.add("a/b/c/d"); + t.add("b/a/c/d"); + t.add("c/b/a/d"); + t.add("d/c/b/a"); + + t.add("a/b/c/d/"); + t.add("/a/b/c/d/"); + + // Simple lookups + assert.deepEqual(t.withSuffix("d"), ["a/b/c/d", "b/a/c/d", "c/b/a/d"]); + assert.deepEqual(t.withSuffix("c/d"), ["a/b/c/d", "b/a/c/d"]); + assert.deepEqual(t.withSuffix("b/c/d"), ["a/b/c/d"]); + assert.deepEqual(t.withSuffix("a/b/c/d"), ["a/b/c/d"]); + assert.deepEqual(t.withSuffix("b/a"), ["d/c/b/a"]); + + // With leading/trailing delimiters + assert.deepEqual(t.withSuffix("c/d/"), ["a/b/c/d/", "/a/b/c/d/"]); + assert.deepEqual(t.withSuffix("/a/b/c/d/"), ["/a/b/c/d/"]); + + // Limited lookups + assert.deepEqual(t.withSuffix("d", 1), ["a/b/c/d"]); + assert.deepEqual(t.withSuffix("d", 2), ["a/b/c/d", "b/a/c/d"]); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/to-markdown-test.js b/app/assets/javascripts/discourse/tests/unit/lib/to-markdown-test.js index 35e4cfb13d..aaa2765f3a 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/to-markdown-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/to-markdown-test.js @@ -404,7 +404,7 @@ helloWorld();consectetur.`;

there is a quote below