Merge branch 'main' into generic-import

This commit is contained in:
Gerhard Schlager 2021-12-01 15:31:44 +01:00
commit 3ae2011e47
529 changed files with 12373 additions and 7450 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)
@ -441,7 +443,7 @@ GEM
sprockets (3.7.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.0)
sprockets-rails (3.4.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
@ -476,6 +478,7 @@ GEM
zeitwerk (2.5.1)
PLATFORMS
aarch64-linux
arm64-darwin-20
ruby
x86_64-darwin-18

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

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

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

@ -112,7 +112,9 @@ export default Component.extend(KeyEnterEscape, {
START_DRAG_EVENTS.forEach((startDragEvent) => {
this.element
.querySelector(".grippie")
?.addEventListener(startDragEvent, this.startDragHandler);
?.addEventListener(startDragEvent, this.startDragHandler, {
passive: false,
});
});
if (this._visualViewportResizing()) {

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 {
@ -27,7 +28,7 @@ import {
import { later, next, schedule, throttle } from "@ember/runloop";
import Component from "@ember/component";
import Composer from "discourse/models/composer";
import ComposerUpload from "discourse/mixins/composer-upload";
import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
@ -71,17 +72,6 @@ export function cleanUpComposerUploadHandler() {
uploadHandlers.length = 0;
}
let uploadProcessorQueue = [];
let uploadProcessorActions = {};
export function addComposerUploadProcessor(queueItem, actionItem) {
uploadProcessorQueue.push(queueItem);
Object.assign(uploadProcessorActions, actionItem);
}
export function cleanUpComposerUploadProcessor() {
uploadProcessorQueue = [];
uploadProcessorActions = {};
}
let uploadPreProcessors = [];
export function addComposerUploadPreProcessor(pluginClass, optionsResolverFn) {
if (!(pluginClass.prototype instanceof BasePlugin)) {
@ -107,18 +97,22 @@ export function cleanUpComposerUploadMarkdownResolver() {
uploadMarkdownResolvers = [];
}
export default Component.extend(ComposerUpload, {
export default Component.extend(ComposerUploadUppy, {
classNameBindings: ["showToolbar:toolbar-visible", ":wmd-controls"],
fileUploadElementId: "file-uploader",
mobileFileUploaderId: "mobile-file-upload",
eventPrefix: "composer",
uploadType: "composer",
uppyId: "composer-editor-uppy",
composerModel: alias("composer"),
composerModelContentKey: "reply",
editorInputClass: ".d-editor-input",
shouldBuildScrollMap: true,
scrollMap: null,
processPreview: true,
uploadMarkdownResolvers,
uploadProcessorActions,
uploadProcessorQueue,
uploadPreProcessors,
uploadHandlers,
@ -360,10 +354,14 @@ export default Component.extend(ComposerUpload, {
});
schedule("afterRender", () => {
input?.addEventListener("touchstart", this._handleInputInteraction);
input?.addEventListener("touchstart", this._handleInputInteraction, {
passive: true,
});
input?.addEventListener("mouseenter", this._handleInputInteraction);
preview?.addEventListener("touchstart", this._handlePreviewInteraction);
preview?.addEventListener("touchstart", this._handlePreviewInteraction, {
passive: true,
});
preview?.addEventListener("mouseenter", this._handlePreviewInteraction);
});
},
@ -561,10 +559,11 @@ export default Component.extend(ComposerUpload, {
_renderUnseenMentions(preview, unseen) {
// 'Create a New Topic' scenario is not supported (per conversation with codinghorror)
// https://meta.discourse.org/t/taking-another-1-7-release-task/51986/7
fetchUnseenMentions(unseen, this.get("composer.topic.id")).then(() => {
fetchUnseenMentions(unseen, this.get("composer.topic.id")).then((r) => {
linkSeenMentions(preview, this.siteSettings);
this._warnMentionedGroups(preview);
this._warnCannotSeeMention(preview);
this._warnHereMention(r.here_count);
});
},
@ -639,6 +638,20 @@ export default Component.extend(ComposerUpload, {
});
},
_warnHereMention(hereCount) {
if (!hereCount || hereCount === 0) {
return;
}
later(
this,
() => {
this.hereMention(hereCount);
},
2000
);
},
@bind
_handleImageScaleButtonClick(event) {
if (!event.target.classList.contains("scale-btn")) {
@ -677,6 +690,38 @@ export default Component.extend(ComposerUpload, {
return;
},
resetImageControls(buttonWrapper) {
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
const readonlyContainer = buttonWrapper.querySelector(
".alt-text-readonly-container"
);
const editContainer = buttonWrapper.querySelector(
".alt-text-edit-container"
);
imageResize.removeAttribute("hidden");
readonlyContainer.removeAttribute("hidden");
buttonWrapper.removeAttribute("editing");
editContainer.setAttribute("hidden", "true");
},
commitAltText(buttonWrapper) {
const index = parseInt(buttonWrapper.getAttribute("data-image-index"), 10);
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
const match = matchingPlaceholder[index];
const input = buttonWrapper.querySelector("input.alt-text-input");
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![${input.value}|$2$3$4]($5)`
);
this.appEvents.trigger("composer:replace-text", match, replacement);
this.resetImageControls(buttonWrapper);
},
@bind
_handleAltTextInputKeypress(event) {
if (!event.target.classList.contains("alt-text-input")) {
@ -688,29 +733,8 @@ export default Component.extend(ComposerUpload, {
}
if (event.key === "Enter") {
const index = parseInt(
$(event.target).closest(".button-wrapper").attr("data-image-index"),
10
);
const matchingPlaceholder = this.get("composer.reply").match(
IMAGE_MARKDOWN_REGEX
);
const match = matchingPlaceholder[index];
const replacement = match.replace(
IMAGE_MARKDOWN_REGEX,
`![${$(event.target).val()}|$2$3$4]($5)`
);
this.appEvents.trigger("composer:replace-text", match, replacement);
const parentContainer = $(event.target).closest(
".alt-text-readonly-container"
);
const altText = parentContainer.find(".alt-text");
const altTextButton = parentContainer.find(".alt-text-edit-btn");
altText.show();
altTextButton.show();
$(event.target).hide();
const buttonWrapper = event.target.closest(".button-wrapper");
this.commitAltText(buttonWrapper);
}
},
@ -720,21 +744,52 @@ export default Component.extend(ComposerUpload, {
return;
}
const parentContainer = $(event.target).closest(
const buttonWrapper = event.target.closest(".button-wrapper");
const imageResize = buttonWrapper.querySelector(".scale-btn-container");
const readonlyContainer = buttonWrapper.querySelector(
".alt-text-readonly-container"
);
const altText = parentContainer.find(".alt-text");
const correspondingInput = parentContainer.find(".alt-text-input");
const altText = readonlyContainer.querySelector(".alt-text");
$(event.target).hide();
altText.hide();
correspondingInput.val(altText.text());
correspondingInput.show();
const editContainer = buttonWrapper.querySelector(
".alt-text-edit-container"
);
const editContainerInput = editContainer.querySelector(".alt-text-input");
buttonWrapper.setAttribute("editing", "true");
imageResize.setAttribute("hidden", "true");
readonlyContainer.setAttribute("hidden", "true");
editContainerInput.value = altText.textContent;
editContainer.removeAttribute("hidden");
editContainerInput.focus();
event.preventDefault();
},
@bind
_handleAltTextOkButtonClick(event) {
if (!event.target.classList.contains("alt-text-edit-ok")) {
return;
}
const buttonWrapper = event.target.closest(".button-wrapper");
this.commitAltText(buttonWrapper);
},
@bind
_handleAltTextCancelButtonClick(event) {
if (!event.target.classList.contains("alt-text-edit-cancel")) {
return;
}
const buttonWrapper = event.target.closest(".button-wrapper");
this.resetImageControls(buttonWrapper);
},
_registerImageAltTextButtonClick(preview) {
preview.addEventListener("click", this._handleAltTextEditButtonClick);
preview.addEventListener("click", this._handleAltTextOkButtonClick);
preview.addEventListener("click", this._handleAltTextCancelButtonClick);
preview.addEventListener("keypress", this._handleAltTextInputKeypress);
},
@ -766,6 +821,8 @@ export default Component.extend(ComposerUpload, {
const preview = this.element.querySelector(".d-editor-preview-wrapper");
preview?.removeEventListener("click", this._handleImageScaleButtonClick);
preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
preview?.removeEventListener("click", this._handleAltTextOkButtonClick);
preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
preview?.removeEventListener("keypress", this._handleAltTextInputKeypress);
},

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

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

@ -95,7 +95,7 @@ export default Component.extend(
didInsertElement() {
this._super(...arguments);
this.bindScrolling({ name: "topic-view" });
this.bindScrolling();
window.addEventListener("resize", this.scrolled);
$(this.element).on(
"click.discourse-redirect",
@ -110,7 +110,7 @@ export default Component.extend(
willDestroyElement() {
this._super(...arguments);
this.unbindScrolling("topic-view");
this.unbindScrolling();
window.removeEventListener("resize", this.scrolled);
// Unbind link tracking

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

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

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

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

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

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

@ -22,15 +22,7 @@ export default Controller.extend(
this.set("showNotifyUsers", false);
if (this.model && this.model.read_restricted) {
Category.reloadBySlugPath(this.model.slug).then((result) => {
this.setProperties({
restrictedGroups: result.category.group_permissions.map(
(g) => g.group_name
),
});
});
} else {
this.setProperties({ restrictedGroups: null });
this.restrictedGroupWarning();
}
},
@ -55,21 +47,6 @@ export default Controller.extend(
);
},
@discourseComputed("restrictedGroups")
hasRestrictedGroups(groups) {
return !!groups;
},
@discourseComputed("restrictedGroups")
restrictedGroupsCount(groups) {
return groups.length;
},
@discourseComputed("restrictedGroups")
restrictedGroupsDisplayText(groups) {
return groups.join(", ");
},
@action
onChangeUsers(usernames) {
this.set("users", usernames.uniq());
@ -128,15 +105,30 @@ export default Controller.extend(
inviteUsers() {
this.set("showNotifyUsers", false);
const controller = showModal("create-invite");
controller.setProperties({
showAdvanced: true,
inviteToTopic: true,
});
controller.set("inviteToTopic", true);
controller.buffered.setProperties({
topicId: this.topic.id,
topicTitle: this.topic.title,
});
controller.save({ autogenerated: true });
},
restrictedGroupWarning() {
this.appEvents.on("modal:body-shown", () => {
let restrictedGroups;
Category.reloadBySlugPath(this.model.slug).then((result) => {
restrictedGroups = result.category.group_permissions.map(
(g) => g.group_name
);
if (restrictedGroups) {
const message = I18n.t("topic.share.restricted_groups", {
count: restrictedGroups.length,
groupNames: restrictedGroups.join(", "),
});
this.flash(message, "warning");
}
});
});
},
}
);

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

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

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

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

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

View File

@ -1,367 +0,0 @@
import Mixin from "@ember/object/mixin";
import I18n from "I18n";
import { next, run } from "@ember/runloop";
import getURL from "discourse-common/lib/get-url";
import { clipboardHelpers } from "discourse/lib/utilities";
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import {
displayErrorForUpload,
getUploadMarkdown,
validateUploadedFiles,
} from "discourse/lib/uploads";
import { cacheShortUploadUrl } from "pretty-text/upload-short-url";
import bootbox from "bootbox";
export default Mixin.create({
_xhr: null,
uploadProgress: 0,
uploadFilenamePlaceholder: null,
uploadProcessingFilename: null,
uploadProcessingPlaceholdersAdded: false,
@discourseComputed("uploadFilenamePlaceholder")
uploadPlaceholder(uploadFilenamePlaceholder) {
const clipboard = I18n.t("clipboard");
const filename = uploadFilenamePlaceholder
? uploadFilenamePlaceholder
: clipboard;
let placeholder = `[${I18n.t("uploading_filename", { filename })}]()\n`;
if (!this._cursorIsOnEmptyLine()) {
placeholder = `\n${placeholder}`;
}
return placeholder;
},
@observes("composer.uploadCancelled")
_cancelUpload() {
if (!this.get("composer.uploadCancelled")) {
return;
}
this.set("composer.uploadCancelled", false);
if (this._xhr) {
this._xhr._userCancelled = true;
this._xhr.abort();
}
this._resetUpload(true);
},
_setUploadPlaceholderSend(data) {
const filename = this._filenamePlaceholder(data);
this.set("uploadFilenamePlaceholder", filename);
// when adding two separate files with the same filename search for matching
// placeholder already existing in the editor ie [Uploading: test.png...]
// and add order nr to the next one: [Uploading: test.png(1)...]
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexString = `\\[${I18n.t("uploading_filename", {
filename: escapedFilename + "(?:\\()?([0-9])?(?:\\))?",
})}\\]\\(\\)`;
const globalRegex = new RegExp(regexString, "g");
const matchingPlaceholder = this.get("composer.reply").match(globalRegex);
if (matchingPlaceholder) {
// get last matching placeholder and its consecutive nr in regex
// capturing group and apply +1 to the placeholder
const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1];
const regex = new RegExp(regexString);
const orderNr = regex.exec(lastMatch)[1]
? parseInt(regex.exec(lastMatch)[1], 10) + 1
: 1;
data.orderNr = orderNr;
const filenameWithOrderNr = `${filename}(${orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
}
},
_setUploadPlaceholderDone(data) {
const filename = this._filenamePlaceholder(data);
if (data.orderNr) {
const filenameWithOrderNr = `${filename}(${data.orderNr})`;
this.set("uploadFilenamePlaceholder", filenameWithOrderNr);
} else {
this.set("uploadFilenamePlaceholder", filename);
}
},
_filenamePlaceholder(data) {
if (data.files) {
return data.files[0].name.replace(/\u200B-\u200D\uFEFF]/g, "");
} else {
return data.name.replace(/\u200B-\u200D\uFEFF]/g, "");
}
},
_resetUploadFilenamePlaceholder() {
this.set("uploadFilenamePlaceholder", null);
},
_resetUpload(removePlaceholder) {
next(() => {
if (this._validUploads > 0) {
this._validUploads--;
}
if (this._validUploads === 0) {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isCancellable: false,
});
}
if (removePlaceholder) {
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder,
""
);
}
this._resetUploadFilenamePlaceholder();
});
},
_bindUploadTarget() {
this._unbindUploadTarget(); // in case it's still bound, let's clean it up first
this._pasted = false;
const $element = $(this.element);
this.setProperties({
uploadProgress: 0,
isUploading: false,
isProcessingUpload: false,
isCancellable: false,
});
$.blueimp.fileupload.prototype.processActions = this.uploadProcessorActions;
$element.fileupload({
url: getURL(`/uploads.json?client_id=${this.messageBus.clientId}`),
dataType: "json",
pasteZone: $element,
processQueue: this.uploadProcessorQueue,
});
$element
.on("fileuploadprocessstart", () => {
this.setProperties({
uploadProgress: 0,
isUploading: true,
isProcessingUpload: true,
isCancellable: false,
});
})
.on("fileuploadprocess", (e, data) => {
if (!this.uploadProcessingPlaceholdersAdded) {
data.originalFiles
.map((f) => f.name)
.forEach((f) => {
this.appEvents.trigger(
"composer:insert-text",
`[${I18n.t("processing_filename", {
filename: f,
})}]()\n`
);
});
this.uploadProcessingPlaceholdersAdded = true;
}
this.uploadProcessingFilename = data.files[data.index].name;
})
.on("fileuploadprocessstop", () => {
this.setProperties({
uploadProgress: 0,
isUploading: false,
isProcessingUpload: false,
isCancellable: false,
});
this.uploadProcessingPlaceholdersAdded = false;
});
$element.on("fileuploadpaste", (e) => {
this._pasted = true;
if (!$(".d-editor-input").is(":focus")) {
return;
}
const { canUpload, canPasteHtml, types } = clipboardHelpers(e, {
siteSettings: this.siteSettings,
canUpload: true,
});
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
e.preventDefault();
}
});
$element.on("fileuploadsubmit", (e, data) => {
const max = this.siteSettings.simultaneous_uploads;
const fileCount = data.files.length;
// Limit the number of simultaneous uploads
if (max > 0 && fileCount > max) {
bootbox.alert(
I18n.t("post.errors.too_many_dragged_and_dropped_files", {
count: max,
})
);
return false;
}
// Look for a matching file upload handler contributed from a plugin
if (fileCount === 1) {
const file = data.files[0];
const matchingHandler = this._findMatchingUploadHandler(file.name);
if (matchingHandler && !matchingHandler.method(file, this)) {
return false;
}
}
// If no plugin, continue as normal
const isPrivateMessage = this.get("composer.privateMessage");
data.formData = { type: "composer" };
if (isPrivateMessage) {
data.formData.for_private_message = true;
}
if (this._pasted) {
data.formData.pasted = true;
}
const opts = {
user: this.currentUser,
siteSettings: this.siteSettings,
isPrivateMessage,
allowStaffToUploadAnyFileInPm: this.siteSettings
.allow_staff_to_upload_any_file_in_pm,
};
const isUploading = validateUploadedFiles(data.files, opts);
run(() => {
this.setProperties({ uploadProgress: 0, isUploading });
});
return isUploading;
});
$element.on("fileuploadprogressall", (e, data) => {
run(() => {
this.set(
"uploadProgress",
parseInt((data.loaded / data.total) * 100, 10)
);
});
});
$element.on("fileuploadsend", (e, data) => {
run(() => {
this._pasted = false;
this._validUploads++;
this._setUploadPlaceholderSend(data);
if (this.uploadProcessingFilename) {
this.appEvents.trigger(
"composer:replace-text",
`[${I18n.t("processing_filename", {
filename: this.uploadProcessingFilename,
})}]()`,
this.uploadPlaceholder.trim()
);
this.uploadProcessingFilename = null;
} else {
this.appEvents.trigger(
"composer:insert-text",
this.uploadPlaceholder
);
}
if (data.xhr && data.originalFiles.length === 1) {
this.set("isCancellable", true);
this._xhr = data.xhr();
}
});
});
$element.on("fileuploaddone", (e, data) => {
run(() => {
let upload = data.result;
this._setUploadPlaceholderDone(data);
if (!this._xhr || !this._xhr._userCancelled) {
const markdown = this.uploadMarkdownResolvers.reduce(
(md, resolver) => resolver(upload) || md,
getUploadMarkdown(upload)
);
cacheShortUploadUrl(upload.short_url, upload);
this.appEvents.trigger(
"composer:replace-text",
this.uploadPlaceholder.trim(),
markdown
);
this._resetUpload(false);
} else {
this._resetUpload(true);
}
});
});
$element.on("fileuploadfail", (e, data) => {
run(() => {
this._setUploadPlaceholderDone(data);
this._resetUpload(true);
const userCancelled = this._xhr && this._xhr._userCancelled;
this._xhr = null;
if (!userCancelled) {
displayErrorForUpload(data, this.siteSettings, data.files[0].name);
}
});
});
},
_bindMobileUploadButton() {
if (this.site.mobileView) {
this.mobileUploadButton = document.getElementById(
this.mobileFileUploaderId
);
this.mobileUploadButtonEventListener = () => {
document.getElementById(this.fileUploadElementId).click();
};
this.mobileUploadButton.addEventListener(
"click",
this.mobileUploadButtonEventListener,
false
);
}
},
_unbindMobileUploadButton() {
this.mobileUploadButton?.removeEventListener(
"click",
this.mobileUploadButtonEventListener
);
},
@on("willDestroyElement")
_unbindUploadTarget() {
this._validUploads = 0;
const $uploadTarget = $(this.element);
try {
$uploadTarget.fileupload("destroy");
} catch (e) {
/* wasn't initialized yet */
}
$uploadTarget.off();
},
showUploadSelector(toolbarEvent) {
this.send("showUploadSelector", toolbarEvent);
},
});

View File

@ -32,8 +32,10 @@ export default Mixin.create({
didInsertElement() {
this._super(...arguments);
window.addEventListener("scroll", this.queueDockCheck);
document.addEventListener("touchmove", this.queueDockCheck);
window.addEventListener("scroll", this.queueDockCheck, { passive: true });
document.addEventListener("touchmove", this.queueDockCheck, {
passive: true,
});
// dockCheck might happen too early on full page refresh
this._initialTimer = later(this, this.safeDockCheck, 50);

View File

@ -33,10 +33,13 @@ export default Mixin.create({
this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e);
this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e);
element.addEventListener("touchstart", this.touchStart);
element.addEventListener("touchmove", this.touchMove);
element.addEventListener("touchend", this.touchEnd);
element.addEventListener("touchcancel", this.touchCancel);
const opts = {
passive: false,
};
element.addEventListener("touchstart", this.touchStart, opts);
element.addEventListener("touchmove", this.touchMove, opts);
element.addEventListener("touchend", this.touchEnd, opts);
element.addEventListener("touchcancel", this.touchCancel, opts);
}
},

View File

@ -1,6 +1,5 @@
import Mixin from "@ember/object/mixin";
import discourseDebounce from "discourse-common/lib/debounce";
import { scheduleOnce } from "@ember/runloop";
import { scheduleOnce, throttle } from "@ember/runloop";
import { inject as service } from "@ember/service";
/**
@ -9,20 +8,18 @@ import { inject as service } from "@ember/service";
easier.
**/
const ScrollingDOMMethods = {
bindOnScroll(onScrollMethod, name) {
name = name || "default";
$(document).bind(`touchmove.discourse-${name}`, onScrollMethod);
$(window).bind(`scroll.discourse-${name}`, onScrollMethod);
bindOnScroll(onScrollMethod) {
document.addEventListener("touchmove", onScrollMethod, { passive: true });
window.addEventListener("scroll", onScrollMethod, { passive: true });
},
unbindOnScroll(name) {
name = name || "default";
$(window).unbind(`scroll.discourse-${name}`);
$(document).unbind(`touchmove.discourse-${name}`);
unbindOnScroll(onScrollMethod) {
document.removeEventListener("touchmove", onScrollMethod);
window.removeEventListener("scroll", onScrollMethod);
},
screenNotFull() {
return $(window).height() > $("#main").height();
return window.height > document.querySelector("#main").offsetHeight;
},
};
@ -30,14 +27,15 @@ const Scrolling = Mixin.create({
router: service(),
// Begin watching for scroll events. By default they will be called at max every 100ms.
// call with {debounce: N} for a diff time
bindScrolling(opts) {
opts = opts || { debounce: 100 };
// call with {throttle: N} to change the throttle spacing
bindScrolling(opts = {}) {
if (!opts.throttle) {
opts.throttle = 100;
}
// So we can not call the scrolled event while transitioning. There is no public API for this :'(
const microLib = this.router._router._routerMicrolib;
let onScrollMethod = () => {
let scheduleScrolled = () => {
if (microLib.activeTransition) {
return;
}
@ -45,20 +43,22 @@ const Scrolling = Mixin.create({
return scheduleOnce("afterRender", this, "scrolled");
};
if (opts.debounce) {
let debouncedScrollMethod = () => {
discourseDebounce(this, onScrollMethod, opts.debounce);
};
ScrollingDOMMethods.bindOnScroll(debouncedScrollMethod, opts.name);
let onScrollMethod;
if (opts.throttle) {
onScrollMethod = () =>
throttle(this, scheduleScrolled, opts.throttle, false);
} else {
ScrollingDOMMethods.bindOnScroll(onScrollMethod, opts.name);
onScrollMethod = scheduleScrolled;
}
this._scrollingMixinOnScrollMethod = onScrollMethod;
ScrollingDOMMethods.bindOnScroll(onScrollMethod);
},
screenNotFull: () => ScrollingDOMMethods.screenNotFull(),
unbindScrolling(name) {
ScrollingDOMMethods.unbindOnScroll(name);
unbindScrolling() {
ScrollingDOMMethods.unbindOnScroll(this._scrollingMixinOnScrollMethod);
},
});

View File

@ -1,5 +1,6 @@
import { bind } from "discourse-common/utils/decorators";
import Mixin from "@ember/object/mixin";
import { generateLinkifyFunction } from "discourse/lib/text";
import toMarkdown from "discourse/lib/to-markdown";
import { action } from "@ember/object";
import { isEmpty } from "@ember/utils";
@ -7,7 +8,6 @@ import { isTesting } from "discourse-common/config/environment";
import {
clipboardHelpers,
determinePostReplaceSelection,
safariHacksDisabled,
} from "discourse/lib/utilities";
import { next, schedule } from "@ember/runloop";
@ -17,6 +17,14 @@ const isInside = (text, regex) => {
};
export default Mixin.create({
init() {
this._super(...arguments);
generateLinkifyFunction(this.markdownOptions || {}).then((linkify) => {
// When pasting links, we should use the same rules to match links as we do when creating links for a cooked post.
this._cachedLinkify = linkify;
});
},
// ensures textarea scroll position is correct
_focusTextArea() {
schedule("afterRender", () => {
@ -87,7 +95,7 @@ export default Mixin.create({
this._$textarea.trigger("change");
if (opts.scroll) {
const oldScrollPos = this._$textarea.scrollTop();
if (!this.capabilities.isIOS || safariHacksDisabled()) {
if (!this.capabilities.isIOS) {
this._$textarea.focus();
}
this._$textarea.scrollTop(oldScrollPos);
@ -242,7 +250,8 @@ export default Mixin.create({
let html = clipboard.getData("text/html");
let handled = false;
const { pre, lineVal } = this._getSelected(null, { lineVal: true });
const selected = this._getSelected(null, { lineVal: true });
const { pre, value: selectedValue, lineVal } = selected;
const isInlinePasting = pre.match(/[^\n]$/);
const isCodeBlock = isInside(pre, /(^|\n)```/g);
@ -272,6 +281,27 @@ export default Mixin.create({
}
}
if (
this._cachedLinkify &&
plainText &&
!handled &&
selected.end > selected.start
) {
if (this._cachedLinkify.test(plainText)) {
const match = this._cachedLinkify.match(plainText)[0];
if (
match &&
match.index === 0 &&
match.lastIndex === match.raw.length
) {
// When specified, linkify supports fuzzy links and emails. Prefer providing the protocol.
// eg: pasting "example@discourse.org" may apply a link format of "mailto:example@discourse.org"
this._addText(selected, `[${selectedValue}](${match.url})`);
handled = true;
}
}
}
if (canPasteHtml && !handled) {
let markdown = toMarkdown(html);

View File

@ -1,4 +1,5 @@
import Mixin from "@ember/object/mixin";
import getUrl from "discourse-common/lib/get-url";
import { bind } from "discourse-common/utils/decorators";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
@ -50,7 +51,7 @@ export default Mixin.create({
data.metadata = { "sha1-checksum": file.meta.sha1_checksum };
}
return ajax("/uploads/create-multipart.json", {
return ajax(getUrl(`${this.uploadRootPath}/create-multipart.json`), {
type: "POST",
data,
// uppy is inconsistent, an error here fires the upload-error event
@ -70,13 +71,16 @@ export default Mixin.create({
if (file.preparePartsRetryAttempts === undefined) {
file.preparePartsRetryAttempts = 0;
}
return ajax("/uploads/batch-presign-multipart-parts.json", {
type: "POST",
data: {
part_numbers: partData.partNumbers,
unique_identifier: file.meta.unique_identifier,
},
})
return ajax(
getUrl(`${this.uploadRootPath}/batch-presign-multipart-parts.json`),
{
type: "POST",
data: {
part_numbers: partData.partNumbers,
unique_identifier: file.meta.unique_identifier,
},
}
)
.then((data) => {
if (file.preparePartsRetryAttempts) {
delete file.preparePartsRetryAttempts;
@ -118,11 +122,15 @@ export default Mixin.create({
@bind
_completeMultipartUpload(file, data) {
if (file.meta.cancelled) {
return;
}
this._uppyInstance.emit("complete-multipart", file.id);
const parts = data.parts.map((part) => {
return { part_number: part.PartNumber, etag: part.ETag };
});
return ajax("/uploads/complete-multipart.json", {
return ajax(getUrl(`${this.uploadRootPath}/complete-multipart.json`), {
type: "POST",
contentType: "application/json",
data: JSON.stringify({
@ -155,7 +163,9 @@ export default Mixin.create({
return;
}
return ajax("/uploads/abort-multipart.json", {
file.meta.cancelled = true;
return ajax(getUrl(`${this.uploadRootPath}/abort-multipart.json`), {
type: "POST",
data: {
external_upload_identifier: uploadId,

View File

@ -1,4 +1,5 @@
import Mixin from "@ember/object/mixin";
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import {
bindFileInputChangeListener,
@ -14,6 +15,7 @@ import XHRUpload from "@uppy/xhr-upload";
import AwsS3 from "@uppy/aws-s3";
import UppyChecksum from "discourse/lib/uppy-checksum-plugin";
import UppyS3Multipart from "discourse/mixins/uppy-s3-multipart";
import UppyChunkedUploader from "discourse/lib/uppy-chunked-uploader-plugin";
import { on } from "discourse-common/utils/decorators";
import { warn } from "@ember/debug";
import bootbox from "bootbox";
@ -25,8 +27,10 @@ export default Mixin.create(UppyS3Multipart, {
uploadProgress: 0,
_uppyInstance: null,
autoStartUploads: true,
_inProgressUploads: 0,
inProgressUploads: null,
id: null,
uploadRootPath: "/uploads",
fileInputSelector: ".hidden-upload-field",
uploadDone() {
warn("You should implement `uploadDone`", {
@ -54,9 +58,10 @@ export default Mixin.create(UppyS3Multipart, {
@on("didInsertElement")
_initialize() {
this.setProperties({
fileInputEl: this.element.querySelector(".hidden-upload-field"),
fileInputEl: this.element.querySelector(this.fileInputSelector),
});
this.set("allowMultipleFiles", this.fileInputEl.multiple);
this.set("inProgressUploads", []);
this._bindFileInputChange();
@ -92,7 +97,11 @@ export default Mixin.create(UppyS3Multipart, {
this.validateUploadedFilesOptions()
);
const isValid = validateUploadedFile(currentFile, validationOpts);
this.setProperties({ uploadProgress: 0, uploading: isValid });
this.setProperties({
uploadProgress: 0,
uploading: isValid && this.autoStartUploads,
filesAwaitingUpload: !this.autoStartUploads,
});
return isValid;
},
@ -141,37 +150,53 @@ export default Mixin.create(UppyS3Multipart, {
});
this._uppyInstance.on("upload", (data) => {
this._inProgressUploads += data.fileIDs.length;
const files = data.fileIDs.map((fileId) =>
this._uppyInstance.getFile(fileId)
);
files.forEach((file) => {
this.inProgressUploads.push(
EmberObject.create({
fileName: file.name,
id: file.id,
progress: 0,
})
);
});
});
this._uppyInstance.on("upload-success", (file, response) => {
this._inProgressUploads--;
this._removeInProgressUpload(file.id);
if (this.usingS3Uploads) {
this.setProperties({ uploading: false, processing: true });
this._completeExternalUpload(file)
.then((completeResponse) => {
this.uploadDone(completeResponse);
this.uploadDone(
deepMerge(completeResponse, { file_name: file.name })
);
if (this._inProgressUploads === 0) {
if (this.inProgressUploads.length === 0) {
this._reset();
}
})
.catch((errResponse) => {
displayErrorForUpload(errResponse, this.siteSettings, file.name);
if (this._inProgressUploads === 0) {
if (this.inProgressUploads.length === 0) {
this._reset();
}
});
} else {
this.uploadDone(response.body);
if (this._inProgressUploads === 0) {
this.uploadDone(
deepMerge(response?.body || {}, { file_name: file.name })
);
if (this.inProgressUploads.length === 0) {
this._reset();
}
}
});
this._uppyInstance.on("upload-error", (file, error, response) => {
this._removeInProgressUpload(file.id);
displayErrorForUpload(response || error, this.siteSettings, file.name);
this._reset();
});
@ -184,7 +209,8 @@ export default Mixin.create(UppyS3Multipart, {
// allow these other uploaders to go direct to S3.
if (
this.siteSettings.enable_direct_s3_uploads &&
!this.preventDirectS3Uploads
!this.preventDirectS3Uploads &&
!this.useChunkedUploads
) {
if (this.useMultipartUploadsIfAvailable) {
this._useS3MultipartUploads();
@ -192,10 +218,24 @@ export default Mixin.create(UppyS3Multipart, {
this._useS3Uploads();
}
} else {
this._useXHRUploads();
if (this.useChunkedUploads) {
this._useChunkedUploads();
} else {
this._useXHRUploads();
}
}
},
_startUpload() {
if (!this.filesAwaitingUpload) {
return;
}
if (!this._uppyInstance?.getFiles().length) {
return;
}
return this._uppyInstance?.upload();
},
_useXHRUploads() {
this._uppyInstance.use(XHRUpload, {
endpoint: this._xhrUploadUrl(),
@ -205,6 +245,16 @@ export default Mixin.create(UppyS3Multipart, {
});
},
_useChunkedUploads() {
this.set("usingChunkedUploads", true);
this._uppyInstance.use(UppyChunkedUploader, {
url: this._xhrUploadUrl(),
headers: {
"X-CSRF-Token": this.session.csrfToken,
},
});
},
_useS3Uploads() {
this.set("usingS3Uploads", true);
this._uppyInstance.use(AwsS3, {
@ -223,7 +273,7 @@ export default Mixin.create(UppyS3Multipart, {
data.metadata = { "sha1-checksum": file.meta.sha1_checksum };
}
return ajax(getUrl("/uploads/generate-presigned-put"), {
return ajax(getUrl(`${this.uploadRootPath}/generate-presigned-put`), {
type: "POST",
data,
})
@ -250,7 +300,7 @@ export default Mixin.create(UppyS3Multipart, {
_xhrUploadUrl() {
return (
getUrl(this.getWithDefault("uploadUrl", "/uploads")) +
getUrl(this.getWithDefault("uploadUrl", this.uploadRootPath)) +
".json?client_id=" +
this.messageBus?.clientId
);
@ -277,7 +327,7 @@ export default Mixin.create(UppyS3Multipart, {
},
_completeExternalUpload(file) {
return ajax(getUrl("/uploads/complete-external-upload"), {
return ajax(getUrl(`${this.uploadRootPath}/complete-external-upload`), {
type: "POST",
data: deepMerge(
{ unique_identifier: file.meta.uniqueUploadIdentifier },
@ -292,7 +342,15 @@ export default Mixin.create(UppyS3Multipart, {
uploading: false,
processing: false,
uploadProgress: 0,
filesAwaitingUpload: false,
});
this.fileInputEl.value = "";
},
_removeInProgressUpload(fileId) {
this.set(
"inProgressUploads",
this.inProgressUploads.filter((upl) => upl.id !== fileId)
);
},
});

View File

@ -86,6 +86,10 @@ export default Mixin.create({
checkUsernameAvailability() {
return User.checkUsername(this.accountUsername, this.accountEmail).then(
(result) => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.set("isDeveloper", false);
if (result.available) {
if (result.is_developer) {

View File

@ -1,4 +1,4 @@
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import I18n from "I18n";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
@ -119,10 +119,7 @@ const Bookmark = RestModel.extend({
return newTags;
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
@discourseComputed("reminder_at", "currentUser")
formattedReminder(bookmarkReminderAt, currentUser) {

View File

@ -15,7 +15,10 @@ const LoginMethod = EmberObject.extend({
@discourseComputed
screenReaderTitle() {
return this.title_override || I18n.t(`login.${this.name}.sr_title`);
return (
this.title_override ||
I18n.t(`login.${this.name}.sr_title`, { defaultValue: this.title })
);
},
@discourseComputed

View File

@ -0,0 +1,28 @@
import discourseComputed from "discourse-common/utils/decorators";
import RestModel from "discourse/models/rest";
import categoryFromId from "discourse-common/utils/category-macro";
import { userPath } from "discourse/lib/url";
import { reads } from "@ember/object/computed";
import { cookAsync } from "discourse/lib/text";
const PendingPost = RestModel.extend({
expandedExcerpt: null,
postUrl: reads("topic_url"),
truncated: false,
init() {
this._super(...arguments);
cookAsync(this.raw_text).then((cooked) => {
this.set("expandedExcerpt", cooked);
});
},
@discourseComputed("username")
userUrl(username) {
return userPath(username.toLowerCase());
},
category: categoryFromId("category_id"),
});
export default PendingPost;

View File

@ -1215,17 +1215,21 @@ export default RestModel.extend({
// Handles an error loading a topic based on a HTTP status code. Updates
// the text to the correct values.
errorLoading(result) {
errorLoading(error) {
const topic = this.topic;
this.set("loadingFilter", false);
topic.set("errorLoading", true);
const json = result.jqXHR.responseJSON;
if (!error.jqXHR) {
throw error;
}
const json = error.jqXHR.responseJSON;
if (json && json.extras && json.extras.html) {
topic.set("errorHtml", json.extras.html);
} else {
topic.set("errorMessage", I18n.t("topic.server_error.description"));
topic.set("noRetry", result.jqXHR.status === 403);
topic.set("noRetry", error.jqXHR.status === 403);
}
},

View File

@ -1,4 +1,4 @@
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import I18n from "I18n";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
@ -24,10 +24,7 @@ const Reviewable = RestModel.extend({
});
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
update(updates) {
// If no changes, do nothing

View File

@ -1,383 +1,10 @@
import EmberObject, { set } from "@ember/object";
import { Promise } from "rsvp";
import RestModel from "discourse/models/rest";
import ResultSet from "discourse/models/result-set";
import { ajax } from "discourse/lib/ajax";
import { getRegister } from "discourse-common/lib/get-owner";
import { underscore } from "@ember/string";
import deprecated from "discourse-common/lib/deprecated";
export { default, flushMap } from "discourse/services/store";
let _identityMap;
// You should only call this if you're a test scaffold
function flushMap() {
_identityMap = {};
}
function storeMap(type, id, obj) {
if (!id) {
return;
deprecated(
`"discourse/models/store" import is deprecated, use "discourse/services/store" instead`,
{
since: "2.8.0.beta8",
dropFrom: "2.9.0.beta1",
}
_identityMap[type] = _identityMap[type] || {};
_identityMap[type][id] = obj;
}
function fromMap(type, id) {
const byType = _identityMap[type];
if (byType && byType.hasOwnProperty(id)) {
return byType[id];
}
}
function removeMap(type, id) {
const byType = _identityMap[type];
if (byType && byType.hasOwnProperty(id)) {
delete byType[id];
}
}
function findAndRemoveMap(type, id) {
const byType = _identityMap[type];
if (byType && byType.hasOwnProperty(id)) {
const result = byType[id];
delete byType[id];
return result;
}
}
flushMap();
export default EmberObject.extend({
_plurals: {
category: "categories",
"post-reply": "post-replies",
"post-reply-history": "post_reply_histories",
reviewable_history: "reviewable_histories",
},
init() {
this._super(...arguments);
this.register = this.register || getRegister(this);
},
pluralize(thing) {
return this._plurals[thing] || thing + "s";
},
addPluralization(thing, plural) {
this._plurals[thing] = plural;
},
findAll(type, findArgs) {
const adapter = this.adapterFor(type);
let store = this;
return adapter.findAll(this, type, findArgs).then((result) => {
let results = this._resultSet(type, result);
if (adapter.afterFindAll) {
results = adapter.afterFindAll(results, {
lookup(subType, id) {
return store._lookupSubType(subType, type, id, result);
},
});
}
return results;
});
},
// Mostly for legacy, things like TopicList without ResultSets
findFiltered(type, findArgs) {
return this.adapterFor(type)
.find(this, type, findArgs)
.then((result) => this._build(type, result));
},
_hydrateFindResults(result, type, findArgs) {
if (typeof findArgs === "object") {
return this._resultSet(type, result, findArgs);
} else {
const apiName = this.adapterFor(type).apiNameFor(type);
return this._hydrate(type, result[underscore(apiName)], result);
}
},
// See if the store can find stale data. We sometimes prefer to show stale data and
// refresh it in the background.
findStale(type, findArgs, opts) {
const stale = this.adapterFor(type).findStale(this, type, findArgs, opts);
return {
hasResults: stale !== undefined,
results: stale,
refresh: () => this.find(type, findArgs, opts),
};
},
find(type, findArgs, opts) {
let adapter = this.adapterFor(type);
return adapter.find(this, type, findArgs, opts).then((result) => {
let hydrated = this._hydrateFindResults(result, type, findArgs, opts);
if (result.extras) {
hydrated.set("extras", result.extras);
}
if (adapter.cache) {
const stale = adapter.findStale(this, type, findArgs, opts);
hydrated = this._updateStale(stale, hydrated, adapter.primaryKey);
adapter.cacheFind(this, type, findArgs, opts, hydrated);
}
return hydrated;
});
},
_updateStale(stale, hydrated, primaryKey) {
if (!stale) {
return hydrated;
}
hydrated.set(
"content",
hydrated.get("content").map((item) => {
let staleItem = stale.content.findBy(primaryKey, item.get(primaryKey));
if (staleItem) {
staleItem.setProperties(item);
} else {
staleItem = item;
}
return staleItem;
})
);
return hydrated;
},
refreshResults(resultSet, type, url) {
const adapter = this.adapterFor(type);
return ajax(url).then((result) => {
const typeName = underscore(this.pluralize(adapter.apiNameFor(type)));
const content = result[typeName].map((obj) =>
this._hydrate(type, obj, result)
);
resultSet.set("content", content);
});
},
appendResults(resultSet, type, url) {
const adapter = this.adapterFor(type);
return ajax(url).then((result) => {
const typeName = underscore(this.pluralize(adapter.apiNameFor(type)));
let pageTarget = result.meta || result;
let totalRows =
pageTarget["total_rows_" + typeName] || resultSet.get("totalRows");
let loadMoreUrl = pageTarget["load_more_" + typeName];
let content = result[typeName].map((obj) =>
this._hydrate(type, obj, result)
);
resultSet.setProperties({ totalRows, loadMoreUrl });
resultSet.get("content").pushObjects(content);
// If we've loaded them all, clear the load more URL
if (resultSet.get("length") >= totalRows) {
resultSet.set("loadMoreUrl", null);
}
});
},
update(type, id, attrs) {
const adapter = this.adapterFor(type);
return adapter.update(this, type, id, attrs, function (result) {
if (result && result[type] && result[type][adapter.primaryKey]) {
const oldRecord = findAndRemoveMap(type, id);
storeMap(type, result[type][adapter.primaryKey], oldRecord);
}
return result;
});
},
createRecord(type, attrs) {
attrs = attrs || {};
const adapter = this.adapterFor(type);
return !!attrs[adapter.primaryKey]
? this._hydrate(type, attrs)
: this._build(type, attrs);
},
destroyRecord(type, record) {
const adapter = this.adapterFor(type);
// If the record is new, don't perform an Ajax call
if (record.get("isNew")) {
removeMap(type, record.get(adapter.primaryKey));
return Promise.resolve(true);
}
return adapter.destroyRecord(this, type, record).then(function (result) {
removeMap(type, record.get(adapter.primaryKey));
return result;
});
},
_resultSet(type, result, findArgs) {
const adapter = this.adapterFor(type);
const typeName = underscore(this.pluralize(adapter.apiNameFor(type)));
if (!result[typeName]) {
// eslint-disable-next-line no-console
console.error(`JSON response is missing \`${typeName}\` key`, result);
return;
}
const content = result[typeName].map((obj) =>
this._hydrate(type, obj, result)
);
let pageTarget = result.meta || result;
const createArgs = {
content,
findArgs,
totalRows: pageTarget["total_rows_" + typeName] || content.length,
loadMoreUrl: pageTarget["load_more_" + typeName],
refreshUrl: pageTarget["refresh_" + typeName],
resultSetMeta: result.meta,
store: this,
__type: type,
};
if (result.extras) {
createArgs.extras = result.extras;
}
return ResultSet.create(createArgs);
},
_build(type, obj) {
const adapter = this.adapterFor(type);
obj.store = this;
obj.__type = type;
obj.__state = obj[adapter.primaryKey] ? "created" : "new";
// TODO: Have injections be automatic
obj.topicTrackingState = this.register.lookup("topic-tracking-state:main");
obj.keyValueStore = this.register.lookup("key-value-store:main");
const klass = this.register.lookupFactory("model:" + type) || RestModel;
const model = klass.create(obj);
storeMap(type, obj[adapter.primaryKey], model);
return model;
},
adapterFor(type) {
return (
this.register.lookup("adapter:" + type) ||
this.register.lookup("adapter:rest")
);
},
_lookupSubType(subType, type, id, root) {
if (root.meta && root.meta.types) {
subType = root.meta.types[subType] || subType;
}
const subTypeAdapter = this.adapterFor(subType);
const pluralType = this.pluralize(subType);
const collection = root[this.pluralize(subType)];
if (collection) {
const hashedProp = "__hashed_" + pluralType;
let hashedCollection = root[hashedProp];
if (!hashedCollection) {
hashedCollection = {};
collection.forEach(function (it) {
hashedCollection[it[subTypeAdapter.primaryKey]] = it;
});
root[hashedProp] = hashedCollection;
}
const found = hashedCollection[id];
if (found) {
const hydrated = this._hydrate(subType, found, root);
hashedCollection[id] = hydrated;
return hydrated;
}
}
},
_hydrateEmbedded(type, obj, root) {
const adapter = this.adapterFor(type);
Object.keys(obj).forEach((k) => {
if (k === adapter.primaryKey) {
return;
}
const m = /(.+)\_id(s?)$/.exec(k);
if (m) {
const subType = m[1];
if (m[2]) {
const hydrated = obj[k].map((id) =>
this._lookupSubType(subType, type, id, root)
);
obj[this.pluralize(subType)] = hydrated || [];
delete obj[k];
} else {
const hydrated = this._lookupSubType(subType, type, obj[k], root);
if (hydrated) {
obj[subType] = hydrated;
delete obj[k];
} else {
set(obj, subType, null);
}
}
}
});
},
_hydrate(type, obj, root) {
if (!obj) {
throw new Error("Can't hydrate " + type + " of `null`");
}
const adapter = this.adapterFor(type);
const id = obj[adapter.primaryKey];
if (!id) {
throw new Error(
`Can't hydrate ${type} without primaryKey: \`${adapter.primaryKey}\``
);
}
root = root || obj;
if (root.__rest_serializer === "1") {
this._hydrateEmbedded(type, obj, root);
}
const existing = fromMap(type, id);
if (existing === obj) {
return existing;
}
if (existing) {
delete obj[adapter.primaryKey];
let klass = this.register.lookupFactory("model:" + type);
if (klass && klass.class) {
klass = klass.class;
}
if (!klass) {
klass = RestModel;
}
existing.setProperties(klass.munge(obj));
obj[adapter.primaryKey] = id;
return existing;
}
return this._build(type, obj);
},
});
export { flushMap };
);

View File

@ -1,7 +1,7 @@
import { alias, and, equal, notEmpty, or } from "@ember/object/computed";
import { fmt, propertyEqual } from "discourse/lib/computed";
import ActionSummary from "discourse/models/action-summary";
import Category from "discourse/models/category";
import categoryFromId from "discourse-common/utils/category-macro";
import Bookmark from "discourse/models/bookmark";
import EmberObject from "@ember/object";
import I18n from "I18n";
@ -15,7 +15,7 @@ import { deepMerge } from "discourse-common/lib/object";
import discourseComputed from "discourse-common/utils/decorators";
import { emojiUnescape } from "discourse/lib/text";
import { fancyTitle } from "discourse/lib/topic-fancy-title";
import { flushMap } from "discourse/models/store";
import { flushMap } from "discourse/services/store";
import getURL from "discourse-common/lib/get-url";
import { longDate } from "discourse/lib/formatter";
import { popupAjaxError } from "discourse/lib/ajax-error";
@ -209,10 +209,7 @@ const Topic = RestModel.extend({
return { type: "topic", id };
},
@discourseComputed("category_id")
category(categoryId) {
return Category.findById(categoryId);
},
category: categoryFromId("category_id"),
@discourseComputed("url")
shareUrl(url) {
@ -493,7 +490,10 @@ const Topic = RestModel.extend({
keys.forEach((key) => this.set(key, json[key]));
if (this.bookmarks.length) {
this.bookmarks = this.bookmarks.map((bm) => Bookmark.create(bm));
this.set(
"bookmarks",
this.bookmarks.map((bm) => Bookmark.create(bm))
);
}
return this;

View File

@ -1,6 +1,6 @@
import { and, equal, or } from "@ember/object/computed";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import Category from "discourse/models/category";
import discourseComputed from "discourse-common/utils/decorators";
import categoryFromId from "discourse-common/utils/category-macro";
import RestModel from "discourse/models/rest";
import User from "discourse/models/user";
import UserActionGroup from "discourse/models/user-action-group";
@ -19,7 +19,6 @@ const UserActionTypes = {
edits: 11,
messages_sent: 12,
messages_received: 13,
pending: 14,
};
const InvertedActionTypes = {};
@ -28,13 +27,7 @@ Object.keys(UserActionTypes).forEach(
);
const UserAction = RestModel.extend({
@on("init")
_attachCategory() {
const categoryId = this.category_id;
if (categoryId) {
this.set("category", Category.findById(categoryId));
}
},
category: categoryFromId("category_id"),
@discourseComputed("action_type")
descriptionKey(action) {

View File

@ -1072,6 +1072,14 @@ User.reopenClass(Singleton, {
return ajax(userPath("check_email"), { data: { email } });
},
loadRecentSearches() {
return ajax(`/u/recent-searches`);
},
resetRecentSearches() {
return ajax(`/u/recent-searches`, { type: "DELETE" });
},
groupStats(stats) {
const responses = UserActionStat.create({
count: 0,

View File

@ -5,12 +5,10 @@ import PrivateMessageTopicTrackingState from "discourse/models/private-message-t
import DiscourseLocation from "discourse/lib/discourse-location";
import KeyValueStore from "discourse/lib/key-value-store";
import MessageBus from "message-bus-client";
import ScreenTrack from "discourse/lib/screen-track";
import SearchService from "discourse/services/search";
import Session from "discourse/models/session";
import Site from "discourse/models/site";
import Store from "discourse/models/store";
import User from "discourse/models/user";
import deprecated from "discourse-common/lib/deprecated";
const ALL_TARGETS = ["controller", "component", "route", "model", "adapter"];
@ -21,9 +19,6 @@ export function registerObjects(app) {
}
app.__registeredObjects__ = true;
app.register("store:main", Store);
app.register("service:store", Store);
// TODO: This should be included properly
app.register("message-bus:main", MessageBus, { instantiate: false });
@ -38,6 +33,17 @@ export default {
initialize(container, app) {
registerObjects(app);
app.register("store:main", {
create() {
deprecated(`"store:main" is deprecated, use "service:store" instead`, {
since: "2.8.0.beta8",
dropFrom: "2.9.0.beta1",
});
return container.lookup("service:store");
},
});
let siteSettings = container.lookup("site-settings:main");
const currentUser = User.current();
@ -69,40 +75,43 @@ export default {
const session = Session.current();
app.register("session:main", session, { instantiate: false });
// TODO: Automatically register this service
const screenTrack = new ScreenTrack(
topicTrackingState,
siteSettings,
session,
currentUser,
container.lookup("service:app-events")
);
app.register("service:screen-track", screenTrack, { instantiate: false });
app.register("location:discourse-location", DiscourseLocation);
const keyValueStore = new KeyValueStore("discourse_");
app.register("key-value-store:main", keyValueStore, { instantiate: false });
app.register("search-service:main", SearchService);
app.register("search-service:main", {
create() {
deprecated(
`"search-service:main" is deprecated, use "service:search" instead`,
{
since: "2.8.0.beta8",
dropFrom: "2.9.0.beta1",
}
);
return container.lookup("service:search");
},
});
ALL_TARGETS.forEach((t) => {
app.inject(t, "appEvents", "service:app-events");
app.inject(t, "topicTrackingState", "topic-tracking-state:main");
app.inject(t, "pmTopicTrackingState", "pm-topic-tracking-state:main");
app.inject(t, "store", "service:store");
app.inject(t, "site", "site:main");
app.inject(t, "searchService", "search-service:main");
app.inject(t, "keyValueStore", "key-value-store:main");
app.inject(t, "searchService", "service:search");
});
ALL_TARGETS.concat("service").forEach((t) => {
app.inject(t, "session", "session:main");
app.inject(t, "messageBus", "message-bus:main");
app.inject(t, "siteSettings", "site-settings:main");
app.inject(t, "topicTrackingState", "topic-tracking-state:main");
app.inject(t, "keyValueStore", "key-value-store:main");
});
if (currentUser) {
["component", "route", "controller", "service"].forEach((t) => {
["controller", "component", "route", "service"].forEach((t) => {
app.inject(t, "currentUser", "current-user:main");
});
}

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