Version bump

This commit is contained in:
Neil Lalonde 2021-12-01 11:43:14 -05:00
commit ca4ac732b8
No known key found for this signature in database
GPG Key ID: FF871CA9037D0A91
595 changed files with 14201 additions and 8265 deletions

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -80,7 +80,7 @@ GEM
rack (>= 0.9.0)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
bootsnap (1.9.1)
bootsnap (1.9.3)
msgpack (~> 1.0)
builder (3.2.4)
bullet (6.1.5)
@ -161,7 +161,7 @@ GEM
ffi (1.15.4)
fspath (3.1.2)
gc_tracer (1.5.1)
globalid (0.5.2)
globalid (1.0.0)
activesupport (>= 5.0)
guess_html_encoding (0.0.11)
hana (1.3.7)
@ -198,6 +198,8 @@ GEM
jwt (2.3.0)
kgio (2.11.4)
libv8-node (16.10.0.0)
libv8-node (16.10.0.0-aarch64-linux)
libv8-node (16.10.0.0-arm64-darwin)
libv8-node (16.10.0.0-x86_64-darwin)
libv8-node (16.10.0.0-x86_64-darwin-19)
libv8-node (16.10.0.0-x86_64-linux)
@ -213,7 +215,7 @@ GEM
logstash-event (1.2.02)
logstash-logger (0.26.1)
logstash-event (~> 1.2)
logster (2.9.8)
logster (2.10.0)
loofah (2.12.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@ -290,7 +292,7 @@ GEM
parallel (1.21.0)
parallel_tests (3.7.3)
parallel
parser (3.0.2.0)
parser (3.0.3.1)
ast (~> 2.4.1)
pg (1.2.3)
progress (3.6.0)
@ -387,7 +389,7 @@ GEM
json-schema (~> 2.2)
railties (>= 3.1, < 7.0)
rtlit (0.0.5)
rubocop (1.22.3)
rubocop (1.23.0)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
@ -440,7 +442,7 @@ GEM
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.3.0)
sprockets-rails (3.4.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
@ -474,6 +476,7 @@ GEM
zeitwerk (2.5.1)
PLATFORMS
aarch64-linux
arm64-darwin-20
ruby
x86_64-darwin-18

View File

@ -5,28 +5,21 @@ import loadScript from "discourse/lib/load-script";
import { makeArray } from "discourse-common/lib/helpers";
import { number } from "discourse/lib/formatter";
import { schedule } from "@ember/runloop";
import { bind } from "discourse-common/utils/decorators";
export default Component.extend({
classNames: ["admin-report-chart", "admin-report-stacked-chart"],
init() {
this._super(...arguments);
this.resizeHandler = () =>
discourseDebounce(this, this._scheduleChartRendering, 500);
},
didInsertElement() {
this._super(...arguments);
$(window).on("resize.chart", this.resizeHandler);
window.addEventListener("resize", this._resizeHandler);
},
willDestroyElement() {
this._super(...arguments);
$(window).off("resize.chart", this.resizeHandler);
window.removeEventListener("resize", this._resizeHandler);
this._resetChart();
},
@ -36,6 +29,11 @@ export default Component.extend({
discourseDebounce(this, this._scheduleChartRendering, 100);
},
@bind
_resizeHandler() {
discourseDebounce(this, this._scheduleChartRendering, 500);
},
_scheduleChartRendering() {
schedule("afterRender", () => {
if (!this.element) {
@ -149,9 +147,7 @@ export default Component.extend({
},
_resetChart() {
if (this._chart) {
this._chart.destroy();
this._chart = null;
}
this._chart?.destroy();
this._chart = null;
},
});

View File

@ -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")

View File

@ -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,

View File

@ -2,15 +2,18 @@ import Component from "@ember/component";
import I18n from "I18n";
import Permalink from "admin/models/permalink";
import bootbox from "bootbox";
import discourseComputed from "discourse-common/utils/decorators";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { fmt } from "discourse/lib/computed";
import { schedule } from "@ember/runloop";
import { action } from "@ember/object";
export default Component.extend({
classNames: ["permalink-form"],
tagName: "",
formSubmitted: false,
permalinkType: "topic_id",
permalinkTypePlaceholder: fmt("permalinkType", "admin.permalink.%@"),
action: null,
permalinkTypeValue: null,
@discourseComputed
permalinkTypes() {
@ -23,70 +26,57 @@ export default Component.extend({
];
},
didInsertElement() {
this._super(...arguments);
schedule("afterRender", () => {
$(this.element.querySelector(".external-url")).keydown((e) => {
if (e.key === "Enter") {
this.send("submit");
}
});
});
},
@bind
focusPermalink() {
schedule("afterRender", () =>
this.element.querySelector(".permalink-url").focus()
this.element.querySelector(".permalink-url")?.focus()
);
},
actions: {
submit() {
if (!this.formSubmitted) {
this.set("formSubmitted", true);
@action
submitFormOnEnter(event) {
if (event.key === "Enter") {
this.onSubmit();
}
},
Permalink.create({
url: this.url,
permalink_type: this.permalinkType,
permalink_type_value: this.permalink_type_value,
})
.save()
.then(
(result) => {
this.setProperties({
url: "",
permalink_type_value: "",
formSubmitted: false,
@action
onSubmit() {
if (!this.formSubmitted) {
this.set("formSubmitted", true);
Permalink.create({
url: this.url,
permalink_type: this.permalinkType,
permalink_type_value: this.permalinkTypeValue,
})
.save()
.then(
(result) => {
this.setProperties({
url: "",
permalinkTypeValue: "",
formSubmitted: false,
});
this.action(Permalink.create(result.permalink));
this.focusPermalink();
},
(e) => {
this.set("formSubmitted", false);
let error;
if (e?.jqXHR?.responseJSON?.errors) {
error = I18n.t("generic_error_with_reason", {
error: e.jqXHR.responseJSON.errors.join(". "),
});
this.action(Permalink.create(result.permalink));
this.focusPermalink();
},
(e) => {
this.set("formSubmitted", false);
let error;
if (
e.jqXHR &&
e.jqXHR.responseJSON &&
e.jqXHR.responseJSON.errors
) {
error = I18n.t("generic_error_with_reason", {
error: e.jqXHR.responseJSON.errors.join(". "),
});
} else {
error = I18n.t("generic_error");
}
bootbox.alert(error, () => this.focusPermalink());
} else {
error = I18n.t("generic_error");
}
);
}
},
onChangePermalinkType(type) {
this.set("permalinkType", type);
},
bootbox.alert(error, this.focusPermalink);
}
);
}
},
});

View File

@ -1,39 +1,24 @@
import { action } from "@ember/object";
import Component from "@ember/component";
import DiscourseURL from "discourse/lib/url";
export default Component.extend({
classNames: ["table", "staff-actions"],
tagName: "",
willDestroyElement() {
$(this.element).off("click.discourse-staff-logs");
},
@action
openLinks(event) {
const dataset = event.target.dataset;
didInsertElement() {
this._super(...arguments);
if (dataset.linkPostId) {
event.preventDefault();
$(this.element).on(
"click.discourse-staff-logs",
"[data-link-post-id]",
(e) => {
let postId = $(e.target).attr("data-link-post-id");
this.store.find("post", dataset.linkPostId).then((post) => {
DiscourseURL.routeTo(post.url);
});
} else if (dataset.linkTopicId) {
event.preventDefault();
this.store.find("post", postId).then((p) => {
DiscourseURL.routeTo(p.get("url"));
});
return false;
}
);
$(this.element).on(
"click.discourse-staff-logs",
"[data-link-topic-id]",
(e) => {
let topicId = $(e.target).attr("data-link-topic-id");
DiscourseURL.routeTo(`/t/${topicId}`);
return false;
}
);
DiscourseURL.routeTo(`/t/${dataset.linkTopicId}`);
}
},
});

View File

@ -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) {

View File

@ -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"

View File

@ -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")}}

View File

@ -1,35 +1,36 @@
<div class="inline-form">
<label>{{i18n "admin.permalink.form.label"}}</label>
<div class="permalink-form">
<div class="inline-form">
<label>{{i18n "admin.permalink.form.label"}}</label>
{{text-field
value=url
disabled=formSubmitted
class="permalink-url"
placeholderKey="admin.permalink.url"
autocorrect="off"
autocapitalize="off"
}}
{{text-field
value=url
disabled=formSubmitted
class="permalink-url"
placeholderKey="admin.permalink.url"
autocorrect="off"
autocapitalize="off"
}}
{{combo-box
content=permalinkTypes
value=permalinkType
onChange=(action (mut permalinkType))
class="permalink-type"
}}
{{combo-box
content=permalinkTypes
value=permalinkType
onChange=(action (mut permalinkType))
class="permalink-type"
}}
{{text-field
value=permalink_type_value
disabled=formSubmitted
class="external-url"
placeholderKey=permalinkTypePlaceholder
autocorrect="off"
autocapitalize="off"
}}
{{text-field
value=permalinkTypeValue
disabled=formSubmitted
placeholderKey=permalinkTypePlaceholder
autocorrect="off"
autocapitalize="off"
keyDown=(action "submitFormOnEnter")
}}
{{d-button
class="btn-default"
action=(action "submit")
disabled=formSubmitted
label="admin.permalink.form.add"
}}
{{d-button
action=(action "onSubmit")
disabled=formSubmitted
label="admin.permalink.form.add"
}}
</div>
</div>

View File

@ -0,0 +1,4 @@
{{!-- template-lint-disable no-invalid-interactive --}}
<div class="table staff-actions" {{on "click" (fn this.openLinks)}}>
{{yield}}
</div>

View File

@ -46,7 +46,7 @@
"loader.js": "^4.7.0"
},
"engines": {
"node": ">= 12.*",
"node": "12.* || 14.* || >= 16",
"npm": "please-use-yarn",
"yarn": ">= 1.21.1"
},

View File

@ -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

View File

@ -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,
}),
]

View File

@ -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);
});
}
}

View File

@ -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) {

View File

@ -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));
});
}

View File

@ -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);
}
});
};
}

View File

@ -46,7 +46,7 @@
"loader.js": "^4.7.0"
},
"engines": {
"node": ">= 12.*",
"node": "12.* || 14.* || >= 16",
"npm": "please-use-yarn",
"yarn": ">= 1.21.1"
},

View File

@ -46,7 +46,7 @@
"loader.js": "^4.7.0"
},
"engines": {
"node": ">= 12.*",
"node": "12.* || 14.* || >= 16",
"npm": "please-use-yarn",
"yarn": ">= 1.21.1"
},

View File

@ -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;
});

View File

@ -46,7 +46,7 @@
"loader.js": "^4.7.0"
},
"engines": {
"node": ">= 12.*",
"node": "12.* || 14.* || >= 16",
"npm": "please-use-yarn",
"yarn": ">= 1.21.1"
},

View File

@ -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`;
},
});

View File

@ -11,9 +11,9 @@ import discourseDebounce from "discourse-common/lib/debounce";
import { headerHeight } from "discourse/components/site-header";
import positioningWorkaround from "discourse/lib/safari-hacks";
const START_EVENTS = "touchstart mousedown";
const DRAG_EVENTS = "touchmove mousemove";
const END_EVENTS = "touchend mouseup";
const START_DRAG_EVENTS = ["touchstart", "mousedown"];
const DRAG_EVENTS = ["touchmove", "mousemove"];
const END_DRAG_EVENTS = ["touchend", "mouseup"];
const THROTTLE_RATE = 20;
@ -54,17 +54,15 @@ export default Component.extend(KeyEnterEscape, {
},
movePanels(size) {
$("#main-outlet").css("padding-bottom", size ? size : "");
document.querySelector("#main-outlet").style.paddingBottom = size
? `${size}px`
: "";
// signal the progress bar it should move!
this.appEvents.trigger("composer:resized");
},
@observes(
"composeState",
"composer.action",
"composer.canEditTopicFeaturedLink"
)
@observes("composeState", "composer.{action,canEditTopicFeaturedLink}")
resize() {
schedule("afterRender", () => {
if (!this.element || this.isDestroying || this.isDestroyed) {
@ -76,8 +74,11 @@ export default Component.extend(KeyEnterEscape, {
},
debounceMove() {
const h = $("#reply-control:not(.saving)").height() || 0;
this.movePanels(h);
let height = 0;
if (!this.element.classList.contains("saving")) {
height = this.element.offsetHeight;
}
this.movePanels(height);
},
keyUp() {
@ -105,45 +106,15 @@ export default Component.extend(KeyEnterEscape, {
},
setupComposerResizeEvents() {
const $composer = $(this.element);
const $grippie = $(this.element.querySelector(".grippie"));
const $document = $(document);
let origComposerSize = 0;
let lastMousePos = 0;
this.origComposerSize = 0;
this.lastMousePos = 0;
const performDrag = (event) => {
$composer.trigger("div-resizing");
this.appEvents.trigger("composer:div-resizing");
$composer.addClass("clear-transitions");
const currentMousePos = mouseYPos(event);
let size = origComposerSize + (lastMousePos - currentMousePos);
const winHeight = $(window).height();
size = Math.min(size, winHeight - headerHeight());
this.movePanels(size);
$composer.height(size);
};
const throttledPerformDrag = ((event) => {
event.preventDefault();
throttle(this, performDrag, event, THROTTLE_RATE);
}).bind(this);
const endDrag = (() => {
this.appEvents.trigger("composer:resize-ended");
$document.off(DRAG_EVENTS, throttledPerformDrag);
$document.off(END_EVENTS, endDrag);
$composer.removeClass("clear-transitions");
$composer.focus();
}).bind(this);
$grippie.on(START_EVENTS, (event) => {
event.preventDefault();
origComposerSize = $composer.height();
lastMousePos = mouseYPos(event);
$document.on(DRAG_EVENTS, throttledPerformDrag);
$document.on(END_EVENTS, endDrag);
this.appEvents.trigger("composer:resize-started");
START_DRAG_EVENTS.forEach((startDragEvent) => {
this.element
.querySelector(".grippie")
?.addEventListener(startDragEvent, this.startDragHandler, {
passive: false,
});
});
if (this._visualViewportResizing()) {
@ -152,6 +123,58 @@ export default Component.extend(KeyEnterEscape, {
}
},
@bind
performDragHandler() {
this.appEvents.trigger("composer:div-resizing");
this.element.classList.add("clear-transitions");
const currentMousePos = mouseYPos(event);
let size = this.origComposerSize + (this.lastMousePos - currentMousePos);
size = Math.min(size, window.innerHeight - headerHeight());
this.movePanels(size);
this.element.style.height = size ? `${size}px` : "";
},
@bind
startDragHandler(event) {
event.preventDefault();
this.origComposerSize = this.element.offsetHeight;
this.lastMousePos = mouseYPos(event);
DRAG_EVENTS.forEach((dragEvent) => {
document.addEventListener(dragEvent, this.throttledPerformDrag);
});
END_DRAG_EVENTS.forEach((endDragEvent) => {
document.addEventListener(endDragEvent, this.endDragHandler);
});
this.appEvents.trigger("composer:resize-started");
},
@bind
endDragHandler() {
this.appEvents.trigger("composer:resize-ended");
DRAG_EVENTS.forEach((dragEvent) => {
document.removeEventListener(dragEvent, this.throttledPerformDrag);
});
END_DRAG_EVENTS.forEach((endDragEvent) => {
document.removeEventListener(endDragEvent, this.endDragHandler);
});
this.element.classList.remove("clear-transitions");
this.element.focus();
},
@bind
throttledPerformDrag(event) {
event.preventDefault();
throttle(this, this.performDragHandler, event, THROTTLE_RATE);
},
@bind
viewportResize() {
const composerVH = window.visualViewport.height * 0.01,
@ -207,10 +230,17 @@ export default Component.extend(KeyEnterEscape, {
willDestroyElement() {
this._super(...arguments);
if (this._visualViewportResizing()) {
window.visualViewport.removeEventListener("resize", this.viewportResize);
}
START_DRAG_EVENTS.forEach((startDragEvent) => {
this.element
.querySelector(".grippie")
?.removeEventListener(startDragEvent, this.startDragHandler);
});
cancel(this._lastKeyTimeout);
},

View File

@ -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",
});

View File

@ -3,6 +3,7 @@ import {
authorizesAllExtensions,
authorizesOneOrMoreImageExtensions,
} from "discourse/lib/uploads";
import { alias } from "@ember/object/computed";
import { BasePlugin } from "@uppy/core";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
import {
@ -12,6 +13,7 @@ import {
tinyAvatar,
} from "discourse/lib/utilities";
import discourseComputed, {
bind,
observes,
on,
} from "discourse-common/utils/decorators";
@ -26,7 +28,7 @@ import {
import { later, next, schedule, throttle } from "@ember/runloop";
import Component from "@ember/component";
import Composer from "discourse/models/composer";
import ComposerUpload from "discourse/mixins/composer-upload";
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
@ -70,17 +72,6 @@ export function cleanUpComposerUploadHandler() {
uploadHandlers.length = 0;
}
let uploadProcessorQueue = [];
let uploadProcessorActions = {};
export function addComposerUploadProcessor(queueItem, actionItem) {
uploadProcessorQueue.push(queueItem);
Object.assign(uploadProcessorActions, actionItem);
}
export function cleanUpComposerUploadProcessor() {
uploadProcessorQueue = [];
uploadProcessorActions = {};
}
let uploadPreProcessors = [];
export function addComposerUploadPreProcessor(pluginClass, optionsResolverFn) {
if (!(pluginClass.prototype instanceof BasePlugin)) {
@ -106,18 +97,22 @@ export function cleanUpComposerUploadMarkdownResolver() {
uploadMarkdownResolvers = [];
}
export default Component.extend(ComposerUpload, {
export default Component.extend(ComposerUploadUppy, {
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
fileUploadElementId: "file-uploader",
mobileFileUploaderId: "mobile-file-upload",
eventPrefix: "composer",
uploadType: "composer",
uppyId: "composer-editor-uppy",
composerModel: alias("composer"),
composerModelContentKey: "reply",
editorInputClass: ".d-editor-input",
shouldBuildScrollMap: true,
scrollMap: null,
processPreview: true,
uploadMarkdownResolvers,
uploadProcessorActions,
uploadProcessorQueue,
uploadPreProcessors,
uploadHandlers,
@ -138,9 +133,7 @@ export default Component.extend(ComposerUpload, {
@discourseComputed
showLink() {
return (
this.currentUser && this.currentUser.get("link_posting_access") !== "none"
);
return this.currentUser && this.currentUser.link_posting_access !== "none";
},
@observes("focusTarget")
@ -189,7 +182,8 @@ export default Component.extend(ComposerUpload, {
};
},
userSearchTerm(term) {
@bind
_userSearchTerm(term) {
const topicId = this.get("topic.id");
// maybe this is a brand new topic, so grab category from composer
const categoryId =
@ -218,34 +212,42 @@ export default Component.extend(ComposerUpload, {
return extensions.map((ext) => `.${ext}`).join();
},
@bind
_afterMentionComplete(value) {
this.composer.set("reply", value);
// ensures textarea scroll position is correct
schedule("afterRender", () => {
const input = this.element.querySelector(".d-editor-input");
input?.blur();
input?.focus();
});
},
@on("didInsertElement")
_composerEditorInit() {
const $input = $(this.element.querySelector(".d-editor-input"));
const $preview = $(this.element.querySelector(".d-editor-preview-wrapper"));
if (this.siteSettings.enable_mentions) {
$input.autocomplete({
template: findRawTemplate("user-selector-autocomplete"),
dataSource: (term) => this.userSearchTerm.call(this, term),
dataSource: this._userSearchTerm,
key: "@",
transformComplete: (v) => v.username || v.name,
afterComplete: (value) => {
this.composer.set("reply", value);
// ensures textarea scroll position is correct
schedule("afterRender", () => $input.blur().focus());
},
afterComplete: this._afterMentionComplete,
triggerRule: (textarea) =>
!inCodeBlock(textarea.value, caretPosition(textarea)),
});
}
if (this._enableAdvancedEditorPreviewSync()) {
this._initInputPreviewSync($input, $preview);
const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper");
this._initInputPreviewSync(input, preview);
} else {
$input.on("scroll", () =>
throttle(this, this._syncEditorAndPreviewScroll, $input, $preview, 20)
);
this.element
.querySelector(".d-editor-input")
?.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
}
// Focus on the body unless we have a title
@ -316,30 +318,51 @@ export default Component.extend(ComposerUpload, {
this.set("shouldBuildScrollMap", true);
},
_initInputPreviewSync($input, $preview) {
@bind
_handleInputInteraction(event) {
const preview = this.element.querySelector(".d-editor-preview-wrapper");
if (!$(preview).is(":visible")) {
return;
}
preview.removeEventListener("scroll", this._handleInputOrPreviewScroll);
event.target.addEventListener("scroll", this._handleInputOrPreviewScroll);
},
@bind
_handleInputOrPreviewScroll(event) {
this._syncScroll(
this._syncEditorAndPreviewScroll,
$(event.target),
$(this.element.querySelector(".d-editor-preview-wrapper"))
);
},
@bind
_handlePreviewInteraction(event) {
this.element
.querySelector(".d-editor-input")
?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
event.target?.addEventListener("scroll", this._handleInputOrPreviewScroll);
},
_initInputPreviewSync(input, preview) {
REBUILD_SCROLL_MAP_EVENTS.forEach((event) => {
this.appEvents.on(event, this, this._resetShouldBuildScrollMap);
});
schedule("afterRender", () => {
$input.on("touchstart mouseenter", () => {
if (!$preview.is(":visible")) {
return;
}
$preview.off("scroll");
$input.on("scroll", () => {
this._syncScroll(this._syncEditorAndPreviewScroll, $input, $preview);
});
input?.addEventListener("touchstart", this._handleInputInteraction, {
passive: true,
});
input?.addEventListener("mouseenter", this._handleInputInteraction);
$preview.on("touchstart mouseenter", () => {
$input.off("scroll");
$preview.on("scroll", () => {
this._syncScroll(this._syncPreviewAndEditorScroll, $input, $preview);
});
preview?.addEventListener("touchstart", this._handlePreviewInteraction, {
passive: true,
});
preview?.addEventListener("mouseenter", this._handlePreviewInteraction);
});
},
@ -353,13 +376,15 @@ export default Component.extend(ComposerUpload, {
},
_teardownInputPreviewSync() {
[
$(this.element.querySelector(".d-editor-input")),
$(this.element.querySelector(".d-editor-preview-wrapper")),
].forEach(($element) => {
$element.off("mouseenter touchstart");
$element.off("scroll");
});
const input = this.element.querySelector(".d-editor-input");
input?.removeEventListener("mouseEnter", this._handleInputInteraction);
input?.removeEventListener("touchstart", this._handleInputInteraction);
input?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
const preview = this.element.querySelector(".d-editor-preview-wrapper");
preview?.removeEventListener("mouseEnter", this._handlePreviewInteraction);
preview?.removeEventListener("touchstart", this._handlePreviewInteraction);
preview?.removeEventListener("scroll", this._handleInputOrPreviewScroll);
REBUILD_SCROLL_MAP_EVENTS.forEach((event) => {
this.appEvents.off(event, this, this._resetShouldBuildScrollMap);
@ -453,6 +478,19 @@ export default Component.extend(ComposerUpload, {
return scrollMap;
},
@bind
_throttledSyncEditorAndPreviewScroll(event) {
const $preview = $(this.element.querySelector(".d-editor-preview-wrapper"));
throttle(
this,
this._syncEditorAndPreviewScroll,
$(event.target),
$preview,
20
);
},
_syncEditorAndPreviewScroll($input, $preview, scrollMap) {
if (this._enableAdvancedEditorPreviewSync()) {
let scrollTop;
@ -521,10 +559,11 @@ export default Component.extend(ComposerUpload, {
_renderUnseenMentions(preview, unseen) {
// 'Create a New Topic' scenario is not supported (per conversation with codinghorror)
// https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7
fetchUnseenMentions(unseen, this.get("composer.topic.id")).then(() => {
fetchUnseenMentions(unseen, this.get("composer.topic.id")).then((r) => {
linkSeenMentions(preview, this.siteSettings);
this._warnMentionedGroups(preview);
this._warnCannotSeeMention(preview);
this._warnHereMention(r.here_count);
});
},
@ -599,91 +638,159 @@ export default Component.extend(ComposerUpload, {
});
},
_registerImageScaleButtonClick($preview) {
$preview.off("click", ".scale-btn").on("click", ".scale-btn", (e) => {
const index = parseInt(
$(e.target).closest(".button-wrapper").attr("data-image-index"),
10
);
const scale = e.target.attributes["data-scale"].value;
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
if (matchingPlaceholder) {
const match = matchingPlaceholder[index];
if (match) {
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![$1|$2, ${scale}%$4]($5)`
);
this.appEvents.trigger(
"composer:replace-text",
matchingPlaceholder[index],
replacement,
{ regex: IMAGE_MARKDOWN_REGEX, index }
);
}
}
e.preventDefault();
_warnHereMention(hereCount) {
if (!hereCount || hereCount === 0) {
return;
});
}
later(
this,
() => {
this.hereMention(hereCount);
},
2000
);
},
_registerImageAltTextButtonClick($preview) {
$preview
.off("click", ".alt-text-edit-btn")
.on("click", ".alt-text-edit-btn", (e) => {
const parentContainer = $(e.target).closest(
".alt-text-readonly-container"
@bind
_handleImageScaleButtonClick(event) {
if (!event.target.classList.contains("scale-btn")) {
return;
}
const index = parseInt(
event.target.closest(".button-wrapper").dataset.imageIndex,
10
);
const scale = event.target.dataset.scale;
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
if (matchingPlaceholder) {
const match = matchingPlaceholder[index];
if (match) {
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![$1|$2, ${scale}%$4]($5)`
);
const altText = parentContainer.find(".alt-text");
const correspondingInput = parentContainer.find(".alt-text-input");
$(e.target).hide();
altText.hide();
correspondingInput.val(altText.text());
correspondingInput.show();
e.preventDefault();
});
this.appEvents.trigger(
"composer:replace-text",
matchingPlaceholder[index],
replacement,
{ regex: IMAGE_MARKDOWN_REGEX, index }
);
}
}
$preview
.off("keypress", ".alt-text-input")
.on("keypress", ".alt-text-input", (e) => {
if (e.key === "[" || e.key === "]") {
e.preventDefault();
}
event.preventDefault();
return;
},
if (e.key === "Enter") {
const index = parseInt(
$(e.target).closest(".button-wrapper").attr("data-image-index"),
10
);
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
const match = matchingPlaceholder[index];
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![${$(e.target).val()}|$2$3$4]($5)`
);
resetImageControls(buttonWrapper) {
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
const readonlyContainer = buttonWrapper.querySelector(
".alt-text-readonly-container"
);
const editContainer = buttonWrapper.querySelector(
".alt-text-edit-container"
);
this.appEvents.trigger("composer:replace-text", match, replacement);
imageResize.removeAttribute("hidden");
readonlyContainer.removeAttribute("hidden");
buttonWrapper.removeAttribute("editing");
editContainer.setAttribute("hidden", "true");
},
const parentContainer = $(e.target).closest(
".alt-text-readonly-container"
);
const altText = parentContainer.find(".alt-text");
const altTextButton = parentContainer.find(".alt-text-edit-btn");
altText.show();
altTextButton.show();
$(e.target).hide();
}
});
commitAltText(buttonWrapper) {
const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10);
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
const match = matchingPlaceholder[index];
const input = buttonWrapper.querySelector("input.alt-text-input");
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![${input.value}|$2$3$4]($5)`
);
this.appEvents.trigger("composer:replace-text", match, replacement);
this.resetImageControls(buttonWrapper);
},
@bind
_handleAltTextInputKeypress(event) {
if (!event.target.classList.contains("alt-text-input")) {
return;
}
if (event.key === "[" || event.key === "]") {
event.preventDefault();
}
if (event.key === "Enter") {
const buttonWrapper = event.target.closest(".button-wrapper");
this.commitAltText(buttonWrapper);
}
},
@bind
_handleAltTextEditButtonClick(event) {
if (!event.target.classList.contains("alt-text-edit-btn")) {
return;
}
const buttonWrapper = event.target.closest(".button-wrapper");
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
const readonlyContainer = buttonWrapper.querySelector(
".alt-text-readonly-container"
);
const altText = readonlyContainer.querySelector(".alt-text");
const editContainer = buttonWrapper.querySelector(
".alt-text-edit-container"
);
const editContainerInput = editContainer.querySelector(".alt-text-input");
buttonWrapper.setAttribute("editing", "true");
imageResize.setAttribute("hidden", "true");
readonlyContainer.setAttribute("hidden", "true");
editContainerInput.value = altText.textContent;
editContainer.removeAttribute("hidden");
editContainerInput.focus();
event.preventDefault();
},
@bind
_handleAltTextOkButtonClick(event) {
if (!event.target.classList.contains("alt-text-edit-ok")) {
return;
}
const buttonWrapper = event.target.closest(".button-wrapper");
this.commitAltText(buttonWrapper);
},
@bind
_handleAltTextCancelButtonClick(event) {
if (!event.target.classList.contains("alt-text-edit-cancel")) {
return;
}
const buttonWrapper = event.target.closest(".button-wrapper");
this.resetImageControls(buttonWrapper);
},
_registerImageAltTextButtonClick(preview) {
preview.addEventListener("click", this._handleAltTextEditButtonClick);
preview.addEventListener("click", this._handleAltTextOkButtonClick);
preview.addEventListener("click", this._handleAltTextCancelButtonClick);
preview.addEventListener("keypress", this._handleAltTextInputKeypress);
},
@on("willDestroyElement")
@ -701,6 +808,22 @@ export default Component.extend(ComposerUpload, {
if (this._enableAdvancedEditorPreviewSync()) {
this._teardownInputPreviewSync();
}
if (!this._enableAdvancedEditorPreviewSync()) {
this.element
.querySelector(".d-editor-input")
?.removeEventListener(
"scroll",
this._throttledSyncEditorAndPreviewScroll
);
}
const preview = this.element.querySelector(".d-editor-preview-wrapper");
preview?.removeEventListener("click", this._handleImageScaleButtonClick);
preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
preview?.removeEventListener("click", this._handleAltTextOkButtonClick);
preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
preview?.removeEventListener("keypress", this._handleAltTextInputKeypress);
},
onExpandPopupMenuOptions(toolbarEvent) {
@ -863,8 +986,8 @@ export default Component.extend(ComposerUpload, {
);
}
this._registerImageScaleButtonClick($preview);
this._registerImageAltTextButtonClick($preview);
preview.addEventListener("click", this._handleImageScaleButtonClick);
this._registerImageAltTextButtonClick(preview);
this.trigger("previewRefreshed", preview);
this.afterRefresh($preview);

View File

@ -76,7 +76,7 @@ export default Component.extend({
shareModal() {
const { topic } = this.composer;
const controller = showModal("share-topic");
const controller = showModal("share-topic", { model: topic.category });
controller.setProperties({
allowInvites:
topic.details.can_invite_to &&

View File

@ -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);
},
});

View File

@ -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();
},
});

View File

@ -5,6 +5,7 @@ import {
translateModKey,
} from "discourse/lib/utilities";
import discourseComputed, {
bind,
observes,
on,
} from "discourse-common/utils/decorators";
@ -21,7 +22,6 @@ import deprecated from "discourse-common/lib/deprecated";
import discourseDebounce from "discourse-common/lib/debounce";
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import { getRegister } from "discourse-common/lib/get-owner";
import { isEmpty } from "@ember/utils";
import { isTesting } from "discourse-common/config/environment";
import { linkSeenHashtags } from "discourse/lib/link-hashtags";
import { linkSeenMentions } from "discourse/lib/link-mentions";
@ -286,31 +286,9 @@ export default Component.extend(TextareaTextManipulation, {
});
// disable clicking on links in the preview
$(this.element.querySelector(".d-editor-preview")).on(
"click.preview",
(e) => {
if (wantsNewWindow(e)) {
return;
}
const $target = $(e.target);
if ($target.is("a.mention")) {
this.appEvents.trigger(
"click.discourse-preview-user-card-mention",
$target
);
}
if ($target.is("a.mention-group")) {
this.appEvents.trigger(
"click.discourse-preview-group-card-mention-group",
$target
);
}
if ($target.is("a")) {
e.preventDefault();
return false;
}
}
);
this.element
.querySelector(".d-editor-preview")
.addEventListener("click", this._handlePreviewLinkClick);
if (this.composerEvents) {
this.appEvents.on("composer:insert-block", this, "_insertBlock");
@ -323,6 +301,32 @@ export default Component.extend(TextareaTextManipulation, {
}
},
@bind
_handlePreviewLinkClick(event) {
if (wantsNewWindow(event)) {
return;
}
if (event.target.tagName === "A") {
if (event.target.classList.contains("mention")) {
this.appEvents.trigger(
"click.discourse-preview-user-card-mention",
$(event.target)
);
}
if (event.target.classList.contains("mention-group")) {
this.appEvents.trigger(
"click.discourse-preview-group-card-mention-group",
$(event.target)
);
}
event.preventDefault();
return false;
}
},
@on("willDestroyElement")
_shutDown() {
if (this.composerEvents) {
@ -334,7 +338,9 @@ export default Component.extend(TextareaTextManipulation, {
this._itsatrap?.destroy();
this._itsatrap = null;
$(this.element.querySelector(".d-editor-preview")).off("click.preview");
this.element
.querySelector(".d-editor-preview")
?.removeEventListener("click", this._handlePreviewLinkClick);
this._previewMutationObserver?.disconnect();
@ -787,28 +793,6 @@ export default Component.extend(TextareaTextManipulation, {
this.set("emojiPickerIsActive", !this.emojiPickerIsActive);
},
emojiSelected(code) {
let selected = this._getSelected();
const captures = selected.pre.match(/\B:(\w*)$/);
if (isEmpty(captures)) {
if (selected.pre.match(/\S$/)) {
this._addText(selected, ` :${code}:`);
} else {
this._addText(selected, `:${code}:`);
}
} else {
let numOfRemovedChars = selected.pre.length - captures[1].length;
selected.pre = selected.pre.slice(
0,
selected.pre.length - captures[1].length
);
selected.start -= numOfRemovedChars;
selected.end -= numOfRemovedChars;
this._addText(selected, `${code}:`);
}
},
toolbarButton(button) {
if (this.disabled) {
return;

View File

@ -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")

View File

@ -7,7 +7,7 @@ import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
import Scrolling from "discourse/mixins/scrolling";
import { alias } from "@ember/object/computed";
import { highlightPost } from "discourse/lib/utilities";
import { observes } from "discourse-common/utils/decorators";
import { bind, observes } from "discourse-common/utils/decorators";
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300;
@ -40,12 +40,11 @@ export default Component.extend(
_enteredTopic() {
// Ember is supposed to only call observers when values change but something
// in our view set up is firing this observer with the same value. This check
// prevents scrolled from being called twice.
const enteredAt = this.enteredAt;
if (enteredAt && this.lastEnteredAt !== enteredAt) {
// prevents scrolled from being called twice
if (this.enteredAt && this.lastEnteredAt !== this.enteredAt) {
this._lastShowTopic = null;
schedule("afterRender", () => this.scrolled());
this.set("lastEnteredAt", enteredAt);
schedule("afterRender", this.scrolled);
this.set("lastEnteredAt", this.enteredAt);
}
},
@ -83,7 +82,7 @@ export default Component.extend(
return;
}
const offset = window.pageYOffset || $("html").scrollTop();
const offset = window.pageYOffset || document.documentElement.scrollTop;
this._lastShowTopic = this.shouldShowTopicInHeader(topic, offset);
if (this._lastShowTopic) {
@ -95,16 +94,14 @@ export default Component.extend(
didInsertElement() {
this._super(...arguments);
this.bindScrolling({ name: "topic-view" });
$(window).on("resize.discourse-on-scroll", () => this.scrolled());
this.bindScrolling();
window.addEventListener("resize", this.scrolled);
$(this.element).on(
"click.discourse-redirect",
".cooked a, a.track-link",
(e) => ClickTrack.trackClick(e, this.siteSettings)
);
this.appEvents.on("discourse:focus-changed", this, "gotFocus");
this.appEvents.on("post:highlight", this, "_highlightPost");
this.appEvents.on("header:update-topic", this, "_updateTopic");
@ -112,8 +109,9 @@ export default Component.extend(
willDestroyElement() {
this._super(...arguments);
this.unbindScrolling("topic-view");
$(window).unbind("resize.discourse-on-scroll");
this.unbindScrolling();
window.removeEventListener("resize", this.scrolled);
// Unbind link tracking
$(this.element).off(
@ -149,31 +147,36 @@ export default Component.extend(
(!this.site.mobileView || this.mobileScrollDirection === "down")
);
},
// The user has scrolled the window, or it is finished rendering and ready for processing.
@bind
scrolled() {
if (this.isDestroyed || this.isDestroying || this._state !== "inDOM") {
return;
}
const offset = window.pageYOffset || $("html").scrollTop();
const offset = window.pageYOffset || document.documentElement.scrollTop;
if (this.dockAt === 0) {
const title = $("#topic-title");
if (title && title.length === 1) {
this.set("dockAt", title.offset().top);
const title = document.querySelector("#topic-title");
if (title) {
this.set(
"dockAt",
title.getBoundingClientRect().top + window.scrollY
);
}
}
this.set("hasScrolled", offset > 0);
const topic = this.topic;
const showTopic = this.shouldShowTopicInHeader(topic, offset);
const showTopic = this.shouldShowTopicInHeader(this.topic, offset);
if (showTopic !== this._lastShowTopic) {
if (showTopic) {
this._showTopicInHeader(topic);
this._showTopicInHeader(this.topic);
} else {
if (!DiscourseURL.isJumpScheduled()) {
const loadingNear = topic.get("postStream.loadingNearPost") || 1;
const loadingNear =
this.topic.get("postStream.loadingNearPost") || 1;
if (loadingNear === 1) {
this._hideTopicInHeader();
}

View File

@ -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();

View File

@ -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");

View File

@ -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;
}
});
},
});

View File

@ -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: "",
});
},
},
});

View File

@ -5,19 +5,11 @@ import {
import Component from "@ember/component";
export default Component.extend({
didInsertElement() {
this._super(...arguments);
$(this.element).on("click.discourse-open-tab", "a", (event) => {
if (event.target && event.target.tagName === "A") {
if (shouldOpenInNewTab(event.target.href)) {
openLinkInNewTab(event.target);
}
click(event) {
if (event?.target?.tagName === "A") {
if (shouldOpenInNewTab(event.target.href)) {
openLinkInNewTab(event.target);
}
});
},
willDestroyElement() {
this._super(...arguments);
$(this.element).off("click.discourse-open-tab", "a");
}
},
});

View File

@ -2,16 +2,9 @@ import ClickTrack from "discourse/lib/click-track";
import Component from "@ember/component";
export default Component.extend({
didInsertElement() {
this._super(...arguments);
$(this.element).on("click.discourse-redirect", "a", (e) => {
return ClickTrack.trackClick(e, this.siteSettings);
});
},
willDestroyElement() {
this._super(...arguments);
$(this.element).off("click.discourse-redirect", "a");
click(event) {
if (event?.target?.tagName === "A") {
return ClickTrack.trackClick(event, this.siteSettings);
}
},
});

View File

@ -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",
});
}
},

View File

@ -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);
},
});

View File

@ -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() {

View File

@ -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");

View File

@ -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`);
}
},

View File

@ -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() {

View File

@ -75,7 +75,7 @@ export default Component.extend({
const duration = moment.duration(statusUpdateAt - moment());
const minutesLeft = duration.asMinutes();
if (minutesLeft > 0 || isDeleteRepliesType || this.basedOnLastPost) {
let durationMinutes = parseInt(this.durationMinutes, 0) || 0;
let durationMinutes = parseInt(this.durationMinutes, 10) || 0;
let options = {
timeLeft: duration.humanize(true),

View File

@ -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);
},
});

View File

@ -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);

View File

@ -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");
},

View File

@ -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

View File

@ -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");
},
}
);

View File

@ -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);
},

View File

@ -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();

View File

@ -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();
},
},
});

View File

@ -275,6 +275,10 @@ export default Controller.extend(ModalFunctionality, {
postAction
.act(this.model, params)
.then(() => {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (!params.skipClose) {
this.send("closeModal");
}
@ -286,7 +290,9 @@ export default Controller.extend(ModalFunctionality, {
});
})
.catch((error) => {
this.send("closeModal");
if (!this.isDestroying && !this.isDestroyed) {
this.send("closeModal");
}
popupAjaxError(error);
});
},

View File

@ -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)) || {};

View File

@ -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) {

View File

@ -232,6 +232,10 @@ export default Controller.extend(ModalFunctionality, {
insert_url: buildShortcut("search_menu.insert_url", {
keys1: ["a"],
}),
full_page_search: buildShortcut("search_menu.full_page_search", {
keys1: [translateModKey("Meta"), "Enter"],
keysDelimiter: PLUS,
}),
},
});
},

View File

@ -160,6 +160,7 @@ export default Controller.extend(ModalFunctionality, {
// Successful login
if (result && result.error) {
this.set("loggingIn", false);
this.clearFlash();
if (
(result.security_key_enabled || result.totp_enabled) &&

View File

@ -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();
}

View File

@ -34,21 +34,20 @@ export default Controller.extend({
@discourseComputed("model.user_fields.@each.value")
userFields() {
let siteUserFields = this.site.get("user_fields");
if (!isEmpty(siteUserFields)) {
const userFields = this.get("model.user_fields");
// Staff can edit fields that are not `editable`
if (!this.get("currentUser.staff")) {
siteUserFields = siteUserFields.filterBy("editable", true);
}
return siteUserFields.sortBy("position").map(function (field) {
const value = userFields
? userFields[field.get("id").toString()]
: null;
return EmberObject.create({ value, field });
});
let siteUserFields = this.site.user_fields;
if (isEmpty(siteUserFields)) {
return;
}
// Staff can edit fields that are not `editable`
if (!this.currentUser.staff) {
siteUserFields = siteUserFields.filterBy("editable", true);
}
return siteUserFields.sortBy("position").map((field) => {
const value = this.model.user_fields?.[field.id.toString()];
return EmberObject.create({ field, value });
});
},
@discourseComputed("model.default_calendar")

View File

@ -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"));
},
});

View File

@ -9,13 +9,21 @@ import showModal from "discourse/lib/show-modal";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import I18n from "I18n";
import Category from "discourse/models/category";
export default Controller.extend(
ModalFunctionality,
bufferedProperty("invite"),
{
topic: null,
restrictedGroups: null,
onShow() {
this.set("showNotifyUsers", false);
if (this.model && this.model.read_restricted) {
this.restrictedGroupWarning();
}
},
@discourseComputed("topic.shareUrl")
@ -97,15 +105,30 @@ export default Controller.extend(
inviteUsers() {
this.set("showNotifyUsers", false);
const controller = showModal("create-invite");
controller.setProperties({
showAdvanced: true,
inviteToTopic: true,
});
controller.set("inviteToTopic", true);
controller.buffered.setProperties({
topicId: this.topic.id,
topicTitle: this.topic.title,
});
controller.save({ autogenerated: true });
},
restrictedGroupWarning() {
this.appEvents.on("modal:body-shown", () => {
let restrictedGroups;
Category.reloadBySlugPath(this.model.slug).then((result) => {
restrictedGroups = result.category.group_permissions.map(
(g) => g.group_name
);
if (restrictedGroups) {
const message = I18n.t("topic.share.restricted_groups", {
count: restrictedGroups.length,
groupNames: restrictedGroups.join(", "),
});
this.flash(message, "warning");
}
});
});
},
}
);

View File

@ -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(

View File

@ -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);
},

View File

@ -86,4 +86,9 @@ export default Controller.extend(BulkTopicSelection, {
this.pmTopicTrackingState.resetIncomingTracking();
return false;
},
@action
refresh() {
this.send("triggerRefresh");
},
});

View File

@ -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",

View File

@ -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) {

View File

@ -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",
});
}

View File

@ -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,
};
}
);
}
},
};

View File

@ -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);

View File

@ -0,0 +1,14 @@
import StickyAvatars from "discourse/lib/sticky-avatars";
export default {
name: "sticky-avatars",
after: "inject-objects",
initialize(container) {
this._stickyAvatars = StickyAvatars.init(container);
},
teardown() {
this._stickyAvatars?.destroy();
},
};

View File

@ -23,7 +23,9 @@ export default {
},
title: "topic.share.help",
action() {
const controller = showModal("share-topic");
const controller = showModal("share-topic", {
model: this.topic.category,
});
controller.setProperties({
allowInvites: this.canInviteTo && !this.inviteDisabled,
topic: this.topic,

View File

@ -46,8 +46,6 @@ const keys = {
let inputTimeout;
export default function (options) {
const autocompletePlugin = this;
if (this.length === 0) {
return;
}
@ -55,13 +53,11 @@ export default function (options) {
if (options === "destroy" || options.updateData) {
cancel(inputTimeout);
$(this)
.off("keyup.autocomplete")
.off("keydown.autocomplete")
.off("paste.autocomplete")
.off("click.autocomplete");
$(window).off("click.autocomplete");
this[0].removeEventListener("keydown", handleKeyDown);
this[0].removeEventListener("keyup", handleKeyUp);
this[0].removeEventListener("paste", handlePaste);
this[0].removeEventListener("click", closeAutocomplete);
window.removeEventListener("click", closeAutocomplete);
if (options === "destroy") {
return;
@ -116,8 +112,12 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = [];
function handlePaste() {
later(() => me.trigger("keydown"), 50);
}
function closeAutocomplete() {
_autoCompletePopper && _autoCompletePopper.destroy();
_autoCompletePopper?.destroy();
if (div) {
div.hide().remove();
@ -276,7 +276,7 @@ export default function (options) {
this.val("");
completeStart = 0;
wrap.click(function () {
autocompletePlugin.focus();
this.focus();
return true;
});
}
@ -309,9 +309,22 @@ export default function (options) {
}
ul.find("li").click(function () {
selectedOption = ul.find("li").index(this);
completeTerm(autocompleteOptions[selectedOption]);
if (!options.single) {
me.focus();
// hack for Gboard, see meta.discourse.org/t/-/187009/24
if (autocompleteOptions == null) {
const opts = { ...options, _gboard_hack_force_lookup: true };
const forcedAutocompleteOptions = dataSource(prevTerm, opts);
forcedAutocompleteOptions?.then((data) => {
updateAutoComplete(data);
completeTerm(autocompleteOptions[selectedOption]);
if (!options.single) {
me.focus();
}
});
} else {
completeTerm(autocompleteOptions[selectedOption]);
if (!options.single) {
me.focus();
}
}
return false;
});
@ -398,7 +411,11 @@ export default function (options) {
}
function dataSource(term, opts) {
if (prevTerm === term) {
const force = opts._gboard_hack_force_lookup;
if (force) {
delete opts._gboard_hack_force_lookup;
}
if (prevTerm === term && !force) {
return SKIP;
}
@ -447,24 +464,17 @@ export default function (options) {
closeAutocomplete();
});
$(window).on("click.autocomplete", () => closeAutocomplete());
$(this).on("click.autocomplete", () => closeAutocomplete());
$(this).on("paste.autocomplete", () => {
later(() => me.trigger("keydown"), 50);
});
function checkTriggerRule(opts) {
return options.triggerRule ? options.triggerRule(me[0], opts) : true;
}
$(this).on("keyup.autocomplete", function (e) {
function handleKeyUp(e) {
if (options.debounced) {
discourseDebounce(this, performAutocomplete, e, INPUT_DELAY);
} else {
performAutocomplete(e);
}
});
}
function performAutocomplete(e) {
if ([keys.esc, keys.enter].indexOf(e.which) !== -1) {
@ -503,7 +513,7 @@ export default function (options) {
}
}
$(this).on("keydown.autocomplete", function (e) {
function handleKeyDown(e) {
let c, i, initial, prev, prevIsGood, stopFound, term, total, userToComplete;
let cp;
@ -565,6 +575,8 @@ export default function (options) {
if (e.which === keys.esc) {
if (div !== null) {
closeAutocomplete();
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
return true;
@ -602,7 +614,9 @@ export default function (options) {
// We're cancelling it, really.
return true;
}
e.stopImmediatePropagation();
e.preventDefault();
return false;
case keys.upArrow:
selectedOption = selectedOption - 1;
@ -610,6 +624,7 @@ export default function (options) {
selectedOption = 0;
}
markSelected();
e.preventDefault();
return false;
case keys.downArrow:
total = autocompleteOptions.length;
@ -621,6 +636,7 @@ export default function (options) {
selectedOption = 0;
}
markSelected();
e.preventDefault();
return false;
case keys.backSpace:
autocompleteOptions = null;
@ -652,7 +668,13 @@ export default function (options) {
return true;
}
}
});
}
window.addEventListener("click", closeAutocomplete);
this[0].addEventListener("click", closeAutocomplete);
this[0].addEventListener("paste", handlePaste);
this[0].addEventListener("keyup", handleKeyUp);
this[0].addEventListener("keydown", handleKeyDown);
return this;
}

View File

@ -19,10 +19,6 @@ configureEyeline();
// Track visible elements on the screen.
export default EmberObject.extend(Evented, {
init() {
this._super(...arguments);
},
update() {
if (_skipUpdate) {
return;

View File

@ -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",
});
}

View File

@ -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;

View File

@ -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":

View File

@ -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",
});
}

View File

@ -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",
});
}

View File

@ -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 });
});
}

View File

@ -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;
}
}

View File

@ -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)) {

View File

@ -44,6 +44,7 @@ export function defaultRenderTag(tag, params) {
href +
" data-tag-name=" +
tag +
(params.description ? ' title="' + params.description + '" ' : "") +
" class='" +
classes.join(" ") +
"'>" +

View File

@ -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,
}) + " ";
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -0,0 +1,116 @@
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import Site from "discourse/models/site";
import { bind } from "discourse-common/utils/decorators";
import { schedule } from "@ember/runloop";
export default class StickyAvatars {
stickyClass = "sticky-avatar";
topicPostSelector = "#topic .post-stream .topic-post";
intersectionObserver = null;
direction = "⬇️";
prevOffset = -1;
static init(container) {
return new this(container).init();
}
constructor(container) {
this.container = container;
}
init() {
if (Site.currentProp("mobileView") || !("IntersectionObserver" in window)) {
return;
}
const appEvents = this.container.lookup("service:app-events");
appEvents.on("topic:current-post-scrolled", this._handlePostNodes);
appEvents.on("topic:scrolled", this._handleScroll);
appEvents.on("page:topic-loaded", this._initIntersectionObserver);
addWidgetCleanCallback("post-stream", this._clearIntersectionObserver);
return this;
}
destroy() {
this.container = null;
}
@bind
_handleScroll(offset) {
if (offset <= 0) {
this.direction = "⬇️";
document
.querySelectorAll(`${this.topicPostSelector}.${this.stickyClass}`)
.forEach((node) => node.classList.remove(this.stickyClass));
} else if (offset > this.prevOffset) {
this.direction = "⬇️";
} else {
this.direction = "⬆️";
}
this.prevOffset = offset;
}
@bind
_handlePostNodes() {
this._clearIntersectionObserver();
this._initIntersectionObserver();
schedule("afterRender", () => {
document.querySelectorAll(this.topicPostSelector).forEach((postNode) => {
this.intersectionObserver.observe(postNode);
const topicAvatarNode = postNode.querySelector(".topic-avatar");
if (!topicAvatarNode || !postNode.querySelector("#post_1")) {
return;
}
const topicMapNode = postNode.querySelector(".topic-map");
if (!topicMapNode) {
return;
}
topicAvatarNode.style.marginBottom = `${topicMapNode.clientHeight}px`;
});
});
}
@bind
_initIntersectionObserver() {
schedule("afterRender", () => {
const headerOffset =
parseInt(
getComputedStyle(document.body).getPropertyValue("--header-offset"),
10
) || 0;
const headerHeight = Math.max(headerOffset, 0);
this.intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio === 1) {
entry.target.classList.remove(this.stickyClass);
return;
}
const postContentHeight = entry.target.querySelector(".contents")
?.clientHeight;
if (
this.direction === "⬆️" ||
postContentHeight > window.innerHeight - headerHeight
) {
entry.target.classList.add(this.stickyClass);
}
});
},
{ threshold: [0.0, 1.0], rootMargin: `-${headerHeight}px 0px 0px 0px` }
);
});
}
@bind
_clearIntersectionObserver() {
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
}
}

View File

@ -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;
}

View File

@ -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));
}

View File

@ -16,7 +16,7 @@ export function currentThemeKey() {
export function currentThemeIds() {
const themeIds = [];
const elem = $(keySelector)[0];
const elem = document.querySelector(keySelector);
if (elem) {
elem.content.split(",").forEach((num) => {
num = parseInt(num, 10);

View File

@ -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)
);
}

View 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();
}
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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")

View File

@ -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);
},
});

Some files were not shown because too many files have changed in this diff Show More