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 988a809e9a..8097db1342 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) @@ -441,7 +443,7 @@ GEM sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.4.0) + sprockets-rails (3.4.1) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) @@ -476,6 +478,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-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/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/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 fb8bf92456..4be24a8df8 100644 --- a/app/assets/javascripts/discourse/app/components/composer-body.js +++ b/app/assets/javascripts/discourse/app/components/composer-body.js @@ -112,7 +112,9 @@ export default Component.extend(KeyEnterEscape, { START_DRAG_EVENTS.forEach((startDragEvent) => { this.element .querySelector(".grippie") - ?.addEventListener(startDragEvent, this.startDragHandler); + ?.addEventListener(startDragEvent, this.startDragHandler, { + passive: false, + }); }); if (this._visualViewportResizing()) { 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 fe511fd6f2..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 { @@ -27,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"; @@ -71,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)) { @@ -107,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, @@ -360,10 +354,14 @@ export default Component.extend(ComposerUpload, { }); schedule("afterRender", () => { - input?.addEventListener("touchstart", this._handleInputInteraction); + input?.addEventListener("touchstart", this._handleInputInteraction, { + passive: true, + }); input?.addEventListener("mouseenter", this._handleInputInteraction); - preview?.addEventListener("touchstart", this._handlePreviewInteraction); + preview?.addEventListener("touchstart", this._handlePreviewInteraction, { + passive: true, + }); preview?.addEventListener("mouseenter", this._handlePreviewInteraction); }); }, @@ -561,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); }); }, @@ -639,6 +638,20 @@ export default Component.extend(ComposerUpload, { }); }, + _warnHereMention(hereCount) { + if (!hereCount || hereCount === 0) { + return; + } + + later( + this, + () => { + this.hereMention(hereCount); + }, + 2000 + ); + }, + @bind _handleImageScaleButtonClick(event) { if (!event.target.classList.contains("scale-btn")) { @@ -677,6 +690,38 @@ export default Component.extend(ComposerUpload, { return; }, + 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" + ); + + imageResize.removeAttribute("hidden"); + readonlyContainer.removeAttribute("hidden"); + buttonWrapper.removeAttribute("editing"); + editContainer.setAttribute("hidden", "true"); + }, + + 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")) { @@ -688,29 +733,8 @@ export default Component.extend(ComposerUpload, { } if (event.key === "Enter") { - const index = parseInt( - $(event.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, - `![${$(event.target).val()}|$2$3$4]($5)` - ); - - this.appEvents.trigger("composer:replace-text", match, replacement); - - const parentContainer = $(event.target).closest( - ".alt-text-readonly-container" - ); - const altText = parentContainer.find(".alt-text"); - const altTextButton = parentContainer.find(".alt-text-edit-btn"); - altText.show(); - altTextButton.show(); - $(event.target).hide(); + const buttonWrapper = event.target.closest(".button-wrapper"); + this.commitAltText(buttonWrapper); } }, @@ -720,21 +744,52 @@ export default Component.extend(ComposerUpload, { return; } - const parentContainer = $(event.target).closest( + const buttonWrapper = event.target.closest(".button-wrapper"); + const imageResize = buttonWrapper.querySelector(".scale-btn-container"); + + const readonlyContainer = buttonWrapper.querySelector( ".alt-text-readonly-container" ); - const altText = parentContainer.find(".alt-text"); - const correspondingInput = parentContainer.find(".alt-text-input"); + const altText = readonlyContainer.querySelector(".alt-text"); - $(event.target).hide(); - altText.hide(); - correspondingInput.val(altText.text()); - correspondingInput.show(); + 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); }, @@ -766,6 +821,8 @@ export default Component.extend(ComposerUpload, { 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); }, 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-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 c1b3d53d8f..d5daaca839 100644 --- a/app/assets/javascripts/discourse/app/components/discourse-topic.js +++ b/app/assets/javascripts/discourse/app/components/discourse-topic.js @@ -95,7 +95,7 @@ export default Component.extend( didInsertElement() { this._super(...arguments); - this.bindScrolling({ name: "topic-view" }); + this.bindScrolling(); window.addEventListener("resize", this.scrolled); $(this.element).on( "click.discourse-redirect", @@ -110,7 +110,7 @@ export default Component.extend( willDestroyElement() { this._super(...arguments); - this.unbindScrolling("topic-view"); + this.unbindScrolling(); window.removeEventListener("resize", this.scrolled); // Unbind link tracking 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/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/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/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/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/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 d1c3657be0..3c54976d9b 100644 --- a/app/assets/javascripts/discourse/app/controllers/share-topic.js +++ b/app/assets/javascripts/discourse/app/controllers/share-topic.js @@ -22,15 +22,7 @@ export default Controller.extend( this.set("showNotifyUsers", false); if (this.model && this.model.read_restricted) { - Category.reloadBySlugPath(this.model.slug).then((result) => { - this.setProperties({ - restrictedGroups: result.category.group_permissions.map( - (g) => g.group_name - ), - }); - }); - } else { - this.setProperties({ restrictedGroups: null }); + this.restrictedGroupWarning(); } }, @@ -55,21 +47,6 @@ export default Controller.extend( ); }, - @discourseComputed("restrictedGroups") - hasRestrictedGroups(groups) { - return !!groups; - }, - - @discourseComputed("restrictedGroups") - restrictedGroupsCount(groups) { - return groups.length; - }, - - @discourseComputed("restrictedGroups") - restrictedGroupsDisplayText(groups) { - return groups.join(", "); - }, - @action onChangeUsers(usernames) { this.set("users", usernames.uniq()); @@ -128,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/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 5645050878..38ab5601e0 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -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; } 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/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/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 02563526b1..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,5 +1,6 @@ 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"; @@ -7,7 +8,6 @@ import { isTesting } from "discourse-common/config/environment"; import { clipboardHelpers, determinePostReplaceSelection, - safariHacksDisabled, } from "discourse/lib/utilities"; import { next, schedule } from "@ember/runloop"; @@ -17,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", () => { @@ -87,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); @@ -242,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); @@ -272,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); 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 6253234297..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,4 +1,5 @@ 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"; @@ -50,7 +51,7 @@ export default Mixin.create({ data.metadata = { "sha1-checksum": file.meta.sha1_checksum }; } - return ajax("/uploads/create-multipart.json", { + return ajax(getUrl(`${this.uploadRootPath}/create-multipart.json`), { type: "POST", data, // uppy is inconsistent, an error here fires the upload-error event @@ -70,13 +71,16 @@ export default Mixin.create({ 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, - }, - }) + 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; @@ -118,11 +122,15 @@ export default Mixin.create({ @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("/uploads/complete-multipart.json", { + return ajax(getUrl(`${this.uploadRootPath}/complete-multipart.json`), { type: "POST", contentType: "application/json", data: JSON.stringify({ @@ -155,7 +163,9 @@ export default Mixin.create({ return; } - return ajax("/uploads/abort-multipart.json", { + file.meta.cancelled = true; + + return ajax(getUrl(`${this.uploadRootPath}/abort-multipart.json`), { type: "POST", data: { external_upload_identifier: uploadId, diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js index cabcfc2ae4..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; }, @@ -141,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(); }); @@ -184,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(); @@ -192,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(), @@ -205,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, { @@ -223,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, }) @@ -250,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 ); @@ -277,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 }, @@ -292,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/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/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 95% rename from app/assets/javascripts/discourse/app/lib/screen-track.js rename to app/assets/javascripts/discourse/app/services/screen-track.js index 5988a33300..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) { 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/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|}}
diff --git a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr index f3db91aab3..da667759fa 100644 --- a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr @@ -73,6 +73,9 @@ {{/if}} -{{number topic.views numberKey="views_long"}} + + {{raw-plugin-outlet name="topic-list-before-view-count"}} + {{number topic.views numberKey="views_long"}} + {{raw "list/activity-column" topic=topic class="num topic-list-data" tagName="td"}} 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/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 ff2de50714..330637850b 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/share-topic.hbs @@ -13,12 +13,6 @@ - {{#if hasRestrictedGroups}} - - {{/if}}