Merge branch 'main' into generic-import
This commit is contained in:
commit
3ae2011e47
24
.github/workflows/ember.yml
vendored
24
.github/workflows/ember.yml
vendored
@ -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
|
||||
|
||||
4
.github/workflows/linting.yml
vendored
4
.github/workflows/linting.yml
vendored
@ -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
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@ -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 }}
|
||||
|
||||
13
Gemfile.lock
13
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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
<div class="backup-options">
|
||||
{{#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")}}
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
"loader.js": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.*",
|
||||
"node": "12.* || 14.* || >= 16",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">= 1.21.1"
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -149,7 +149,7 @@ registerIconRenderer({
|
||||
if (params.label) {
|
||||
html += " aria-hidden='true'";
|
||||
}
|
||||
html += ` xmlns="${SVG_NAMESPACE}"><use xlink:href="#${id}" /></svg>`;
|
||||
html += ` xmlns="${SVG_NAMESPACE}"><use href="#${id}" /></svg>`;
|
||||
if (params.label) {
|
||||
html += `<span class='sr-only'>${escape(params.label)}</span>`;
|
||||
}
|
||||
@ -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,
|
||||
}),
|
||||
]
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
"loader.js": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.*",
|
||||
"node": "12.* || 14.* || >= 16",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">= 1.21.1"
|
||||
},
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
"loader.js": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.*",
|
||||
"node": "12.* || 14.* || >= 16",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">= 1.21.1"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
"loader.js": "^4.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.*",
|
||||
"node": "12.* || 14.* || >= 16",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">= 1.21.1"
|
||||
},
|
||||
|
||||
@ -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`;
|
||||
},
|
||||
});
|
||||
@ -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()) {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
@ -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,
|
||||
``
|
||||
);
|
||||
|
||||
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,
|
||||
``
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -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: "",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
@ -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() {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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)) || {};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
},
|
||||
});
|
||||
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
|
||||
@ -86,4 +86,9 @@ export default Controller.extend(BulkTopicSelection, {
|
||||
this.pmTopicTrackingState.resetIncomingTracking();
|
||||
return false;
|
||||
},
|
||||
|
||||
@action
|
||||
refresh() {
|
||||
this.send("triggerRefresh");
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -19,10 +19,6 @@ configureEyeline();
|
||||
|
||||
// Track visible elements on the screen.
|
||||
export default EmberObject.extend(Evented, {
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
},
|
||||
|
||||
update() {
|
||||
if (_skipUpdate) {
|
||||
return;
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 "<svg class=\"fa d-icon d-icon-far-smile svg-icon\" aria-hidden=\"true\"><use xlink:href=\"#far-smile\"></use></svg>";
|
||||
* return "<svg class=\"fa d-icon d-icon-far-smile svg-icon\" aria-hidden=\"true\"><use href=\"#far-smile\"></use></svg>";
|
||||
* },
|
||||
*
|
||||
* // 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)) {
|
||||
|
||||
@ -44,6 +44,7 @@ export function defaultRenderTag(tag, params) {
|
||||
href +
|
||||
" data-tag-name=" +
|
||||
tag +
|
||||
(params.description ? ' title="' + params.description + '" ' : "") +
|
||||
" class='" +
|
||||
classes.join(" ") +
|
||||
"'>" +
|
||||
|
||||
@ -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,
|
||||
}) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
339
app/assets/javascripts/discourse/app/lib/uppy-chunked-upload.js
Normal file
339
app/assets/javascripts/discourse/app/lib/uppy-chunked-upload.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
28
app/assets/javascripts/discourse/app/models/pending-post.js
Normal file
28
app/assets/javascripts/discourse/app/models/pending-post.js
Normal file
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user