DEV: Purge the widget rendering system (#36109)

This commit removes the legacy widget rendering system from the Discourse codebase.

See the topics below for more information:

- https://meta.discourse.org/t/upcoming-eol-for-the-widget-rendering-system/375332
- https://meta.discourse.org/t/upcoming-post-stream-changes-how-to-prepare-themes-and-plugins/372063
This commit is contained in:
Sérgio Saquetim
2025-11-26 16:10:02 -03:00
committed by GitHub
parent f56e1b1b59
commit c2c1cd978e
181 changed files with 8592 additions and 19714 deletions
@@ -527,7 +527,6 @@ nav.post-controls {
}
}
.widget-button, // TODO (glimmer-post-stream): Remove this when glimmer-post-stream is enabled
.btn {
&:hover {
color: currentcolor;
@@ -983,7 +982,6 @@ aside.quote {
}
&.edits {
.widget-button, // TODO (glimmer-post-stream): Remove this when glimmer-post-stream is enabled
.btn {
display: flex;
align-items: center;
@@ -77,7 +77,6 @@ class CurrentUserSerializer < BasicUserSerializer
:login_method,
:has_unseen_features,
:can_see_emails,
:use_glimmer_post_stream_mode_auto_mode,
:can_localize_content?,
:effective_locale,
:use_reviewable_ui_refresh,
@@ -332,14 +331,6 @@ class CurrentUserSerializer < BasicUserSerializer
object.staff?
end
def use_glimmer_post_stream_mode_auto_mode
true
end
def include_use_glimmer_post_stream_mode_auto_mode?
scope.user.in_any_groups?(SiteSetting.glimmer_post_stream_mode_auto_groups_map)
end
def can_localize_content?
scope.can_localize_content?
end
-3
View File
@@ -2745,9 +2745,6 @@ en:
default_navigation_menu_categories: "Selected categories will be displayed under Navigation Menu's Categories section by default."
default_navigation_menu_tags: "Selected tags will be displayed under Navigation Menu's Tags section by default."
experimental_new_new_view_groups: 'Enable a new topics list that combines unread and new topics and make the "Everything" link in the sidebar link to it.'
glimmer_post_stream_mode: "Control whether the new 'glimmer' post stream implementation is used. 'auto' will enable automatically once all your themes and plugins are ready."
glimmer_post_stream_mode_auto_groups: "Enable the new 'glimmer' post stream implementation in 'auto' mode for the specified user groups."
deactivate_widgets_rendering: "Disable the legacy widgets rendering engine. This will disable all widgets that have not been updated to Glimmer components, it will also force the Glimmer Post Stream to be enabled unconditionally regardless of the value of the `glimmer_post_stream_mode` setting."
experimental_form_templates: "Enable the form templates feature. Manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
show_preview_for_form_templates: "Enable the preview for form templates feature"
lazy_load_categories_groups: "Lazy load category information only for users of these groups. This improves performance on sites with many categories."
-21
View File
@@ -4254,27 +4254,6 @@ experimental:
allow_any: false
refresh: true
area: "group_permissions|navigation|experimental"
glimmer_post_stream_mode_auto_groups:
client: true
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
area: "experimental"
glimmer_post_stream_mode:
client: true
type: enum
choices:
- disabled
- auto
- enabled
default: "enabled"
area: "experimental"
deactivate_widgets_rendering:
client: true
default: true
area: "experimental"
enable_rich_text_paste:
client: true
default: true
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class RemoveWidgetPoststreamSettings < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE FROM site_settings
WHERE name IN (
'deactivate_widgets_rendering',
'glimmer_post_stream_mode',
'glimmer_post_stream_mode_auto_groups'
)
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
-1
View File
@@ -24,7 +24,6 @@
"decorator-transforms": "^2.3.0",
"discourse": "workspace:0.0.0",
"discourse-plugins": "workspace:1.0.0",
"discourse-widget-hbs": "workspace:1.0.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-source": "~6.6.0",
"ember-this-fallback": "^0.4.0",
-2
View File
@@ -7,7 +7,6 @@ import colocatedBabelPlugin from "ember-cli-htmlbars/lib/colocated-babel-plugin"
import EmberThisFallback from "ember-this-fallback";
import { memfs } from "memfs";
import transformActionSyntax from "discourse-plugins/transform-action-syntax";
import { WidgetHbsCompiler } from "discourse-widget-hbs/lib/widget-hbs-compiler";
import { browsers } from "../discourse/config/targets";
import babelTransformModuleRenames from "../discourse/lib/babel-transform-module-renames";
import AddThemeGlobals from "./add-theme-globals";
@@ -61,7 +60,6 @@ globalThis.rollup = function (modules, opts) {
babelTransformModuleRenames,
AddThemeGlobals,
colocatedBabelPlugin,
WidgetHbsCompiler,
[
HTMLBarsInlinePrecompile,
{
-1
View File
@@ -10,7 +10,6 @@
"dependencies": {
"@babel/core": "^7.28.5",
"deprecation-silencer": "workspace:1.0.0",
"discourse-widget-hbs": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.3.0",
-1
View File
@@ -1 +0,0 @@
engine-strict = true
@@ -1,11 +0,0 @@
import { dateNode } from "discourse/helpers/node";
import { iconNode } from "discourse/lib/icon-library";
import { avatarFor as avatar } from "discourse/widgets/post";
import rawHtml from "discourse/widgets/raw-html";
export default {
avatar,
dateNode,
iconNode,
rawHtml,
};
@@ -1,13 +0,0 @@
"use strict";
const EmberAddon = require("ember-cli/lib/broccoli/ember-addon");
module.exports = function (defaults) {
const app = new EmberAddon(defaults, {
autoImport: {
publicAssetURL: "",
},
});
return app.toTree();
};
-30
View File
@@ -1,30 +0,0 @@
"use strict";
const widgetHbsCompilerPath = require.resolve("./lib/widget-hbs-compiler");
module.exports = {
name: require("./package").name,
included() {
this._super.included.apply(this, arguments);
let addonOptions = this._getAddonOptions();
addonOptions.babel = addonOptions.babel || {};
addonOptions.babel.plugins = addonOptions.babel.plugins || [];
let babelPlugins = addonOptions.babel.plugins;
babelPlugins.push({
_parallelBabel: {
requireFile: widgetHbsCompilerPath,
useMethod: "WidgetHbsCompiler",
},
});
},
_getAddonOptions() {
return (
(this.parent && this.parent.options) ||
(this.app && this.app.options) ||
{}
);
},
};
@@ -1,373 +0,0 @@
function resolve(path) {
if (path.startsWith("settings") || path.startsWith("transformed")) {
return `this.${path}`;
}
return path;
}
function sexpValue(value) {
if (!value) {
return;
}
if (value.type === "PathExpression") {
return resolve(value.original);
} else if (value.type === "StringLiteral") {
return JSON.stringify(value.value);
} else if (value.type === "SubExpression") {
return sexp(value);
} else {
return resolve(value.value);
}
}
function pairsToObj(pairs) {
let result = [];
pairs.forEach((p) => {
result.push(`"${p.key}": ${sexpValue(p.value)}`);
});
return `{ ${result.join(", ")} }`;
}
function i18n(node) {
let key = sexpValue(node.params[0]);
let hash = node.hash;
if (hash.pairs.length) {
return `I18n.t(${key}, ${pairsToObj(hash.pairs)})`;
}
return `I18n.t(${key})`;
}
function sexp(value) {
if (value.path.original === "hash") {
return pairsToObj(value.hash.pairs);
}
if (value.path.original === "concat") {
let result = [];
value.params.forEach((p) => {
result.push(sexpValue(p));
});
return result.join(" + ");
}
if (value.path.original === "i18n") {
return i18n(value);
}
}
function valueOf(value) {
if (value.type === "SubExpression") {
return sexp(value);
} else if (value.type === "PathExpression") {
return value.original;
} else if (value.type === "StringLiteral") {
return JSON.stringify(value.value);
}
}
function argValue(arg) {
return valueOf(arg.value);
}
function useHelper(state, name) {
let id = state.helpersUsed[name];
if (!id) {
id = ++state.helperNumber;
state.helpersUsed[name] = id;
}
return `__h${id}`;
}
function mustacheValue(node, state) {
let path = node.path.original;
switch (path) {
case "attach":
let widgetName = argValue(
node.hash.pairs.find((p) => p.key === "widget")
);
const attrs = node.hash.pairs.find((p) => p.key === "attrs");
const opts = node.hash.pairs.find((p) => p.key === "opts");
const otherOpts = node.hash.pairs.find((p) => p.key === "otherOpts");
return `this.attach(${widgetName}, ${attrs ? argValue(attrs) : attrs}, ${
opts ? argValue(opts) : opts
}, ${otherOpts ? argValue(otherOpts) : otherOpts})`;
case "yield":
return `this.attrs.contents()`;
case "i18n":
return i18n(node);
case "avatar":
let template = argValue(
node.hash.pairs.find((p) => p.key === "template")
);
let username = argValue(
node.hash.pairs.find((p) => p.key === "username")
);
let size = argValue(node.hash.pairs.find((p) => p.key === "size"));
return `${useHelper(
state,
"avatar"
)}(${size}, { template: ${template}, username: ${username} })`;
case "date":
return `${useHelper(state, "dateNode")}(${valueOf(node.params[0])})`;
case "d-icon":
return `${useHelper(state, "iconNode")}(${valueOf(node.params[0])})`;
default:
// Shortcut: If our mustache has hash arguments, we can assume it's attaching.
// For example `{{home-logo count=123}}` can become `this.attach('home-logo, { "count": 123 });`
let hash = node.hash;
if (hash.pairs.length) {
let widgetString = JSON.stringify(path);
// magic: support applying of attrs. This is commonly done like `{{home-logo attrs=attrs}}`
let firstPair = hash.pairs[0];
if (firstPair.key === "attrs") {
return `this.attach(${widgetString}, ${firstPair.value.original})`;
}
return `this.attach(${widgetString}, ${pairsToObj(hash.pairs)})`;
}
if (node.trusting) {
return `new ${useHelper(state, "rawHtml")}({ html: '<span>' + ${resolve(
path
)} + '</span>'})`;
} else {
return `${resolve(path)}`;
}
}
}
class Compiler {
constructor(ast) {
this.idx = 0;
this.ast = ast;
this.state = {
helpersUsed: {},
helperNumber: 0,
};
}
newAcc() {
return `_a${this.idx++}`;
}
processNode(parentAcc, node) {
let instructions = [];
let innerAcc;
switch (node.type) {
case "Program":
case "Template":
node.body.forEach((bodyNode) => {
instructions = instructions.concat(
this.processNode(parentAcc, bodyNode)
);
});
break;
case "ElementNode":
innerAcc = this.newAcc();
instructions.push(`var ${innerAcc} = [];`);
node.children.forEach((child) => {
instructions = instructions.concat(this.processNode(innerAcc, child));
});
if (node.attributes.length) {
let attributes = [];
let properties = [];
node.attributes.forEach((a) => {
const name = a.name;
const value =
a.value.type === "MustacheStatement"
? mustacheValue(a.value, this.state)
: `"${a.value.chars}"`;
if (a.name === "class") {
properties.push(`"className":${value}`);
} else {
attributes.push(`"${name}":${value}`);
}
});
properties.push(`"attributes":{${attributes.join(", ")}}`);
const propertiesString = `{${properties.join(", ")}}`;
instructions.push(
`${parentAcc}.push(virtualDom.h('${node.tag}', ${propertiesString}, ${innerAcc}));`
);
} else {
instructions.push(
`${parentAcc}.push(virtualDom.h('${node.tag}', ${innerAcc}));`
);
}
break;
case "TextNode":
return `${parentAcc}.push(${JSON.stringify(node.chars)});`;
case "MustacheStatement":
const value = mustacheValue(node, this.state);
if (value) {
instructions.push(`${parentAcc}.push(${value});`);
}
break;
case "BlockStatement":
let negate = "";
switch (node.path.original) {
case "unless":
negate = "!";
// eslint-disable-next-line no-fallthrough
case "if":
instructions.push(
`if (${negate}${resolve(node.params[0].original)}) {`
);
node.program.body.forEach((child) => {
instructions = instructions.concat(
this.processNode(parentAcc, child)
);
});
if (node.inverse) {
instructions.push(`} else {`);
node.inverse.body.forEach((child) => {
instructions = instructions.concat(
this.processNode(parentAcc, child)
);
});
}
instructions.push(`}`);
break;
case "each":
const collection = resolve(node.params[0].original);
instructions.push(`if (${collection} && ${collection}.length) {`);
instructions.push(
` ${collection}.forEach(${node.program.blockParams[0]} => {`
);
node.program.body.forEach((child) => {
instructions = instructions.concat(
this.processNode(parentAcc, child)
);
});
instructions.push(` });`);
instructions.push("}");
break;
}
break;
default:
break;
}
return instructions.join("\n");
}
compile() {
return this.processNode("_r", this.ast);
}
}
const defaultGlimmer = require("@glimmer/syntax");
function compile(template, glimmer) {
if (!glimmer) {
glimmer = defaultGlimmer;
}
const compiled = glimmer.preprocess(template);
const compiler = new Compiler(compiled);
let code = compiler.compile();
let imports = "";
Object.keys(compiler.state.helpersUsed).forEach((h) => {
let id = compiler.state.helpersUsed[h];
imports += `var __h${id} = __widget_helpers.${h}; `;
});
return `function(attrs, state) { ${imports}var _r = [];\n${code}\nreturn _r; }`;
}
exports.compile = compile;
function error(path, state, msg) {
const filename = state.file.opts.filename;
return path.replaceWithSourceString(
`function() { console.error("${filename}: ${msg}"); }`
);
}
const WidgetHbsCompiler = function (babel) {
let t = babel.types;
return {
visitor: {
ImportDeclaration(path, state) {
let node = path.node;
if (
t.isLiteral(node.source, { value: "discourse/widgets/hbs-compiler" })
) {
let first = node.specifiers && node.specifiers[0];
if (!t.isImportDefaultSpecifier(first)) {
let input = state.file.code;
let usedImportStatement = input.slice(node.start, node.end);
let msg = `Only \`import hbs from 'discourse/widgets/hbs-compiler'\` is supported. You used: \`${usedImportStatement}\``;
throw path.buildCodeFrameError(msg);
}
state.importId =
state.importId ||
path.scope.generateUidIdentifierBasedOnNode(path.node.id);
path.scope.rename(first.local.name, state.importId.name);
path.remove();
}
},
TaggedTemplateExpression(path, state) {
if (!state.importId) {
return;
}
let tagPath = path.get("tag");
if (tagPath.node.name !== state.importId.name) {
return;
}
if (path.node.quasi.expressions.length) {
return error(
path,
state,
"placeholders inside a tagged template string are not supported"
);
}
let template = path.node.quasi.quasis
.map((quasi) => quasi.value.cooked)
.join("");
try {
path.replaceWithSourceString(
compile(template, WidgetHbsCompiler.glimmer)
);
} catch (e) {
// eslint-disable-next-line no-console
console.error("widget hbs error", e.toString());
return error(path, state, e.toString());
}
},
},
};
};
WidgetHbsCompiler.cacheKey = () => "discourse-widget-hbs";
exports.WidgetHbsCompiler = WidgetHbsCompiler;
@@ -1,49 +0,0 @@
{
"name": "discourse-widget-hbs",
"version": "1.0.0",
"description": "Support for Discourse's inline `hbs` compiler for widgets",
"author": "Discourse",
"license": "GPL-2.0-only",
"keywords": [
"ember-addon"
],
"scripts": {
"build": "ember build",
"lint:hbs": "ember-template-lint .",
"lint:js": "eslint .",
"start": "ember serve"
},
"dependencies": {
"@babel/core": "^7.28.5",
"ember-auto-import": "^2.12.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0"
},
"devDependencies": {
"@ember/optional-features": "^2.2.0",
"@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^2.0.0",
"@glimmer/syntax": "0.93.1",
"broccoli-asset-rev": "^3.0.0",
"ember-cli": "~6.8.0",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
"ember-cli-terser": "^4.0.2",
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^3.0.1",
"ember-resolver": "^13.1.1",
"ember-source": "~6.6.0",
"ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0",
"webpack": "5.99.9"
},
"engines": {
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": "^9"
},
"ember": {
"edition": "default"
}
}
@@ -15,6 +15,7 @@ import $ from "jquery";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
import DEditor from "discourse/components/d-editor";
import DEditorPreview from "discourse/components/d-editor-preview";
import { applyHtmlDecorators } from "discourse/components/decorated-html";
import Wrapper from "discourse/components/form-template-field/wrapper";
import PickFilesButton from "discourse/components/pick-files-button";
import PostTranslationEditor from "discourse/components/post-translation-editor";
@@ -609,11 +610,7 @@ export default class ComposerEditor extends Component {
}
_decorateCookedElement(preview, helper) {
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
preview,
helper
);
applyHtmlDecorators(preview, helper);
}
@debounce(DEBOUNCE_JIT_MS)
@@ -9,10 +9,89 @@ import {
isRailsTesting,
isTesting,
} from "discourse/lib/environment";
import { POST_STREAM_DEPRECATION_OPTIONS } from "discourse/widgets/widget";
import { WIDGET_DECOMMISSION_OPTIONS } from "discourse/widgets/widget";
const detachedDocument = document.implementation.createHTMLDocument("detached");
/** @type {Symbol} Default decorator type used when no specific type is provided */
export const NON_STREAM_HTML_DECORATOR = Symbol("non-stream");
/** @type {Object.<Symbol|string, Function[]>} Storage for HTML decorators organized by type */
let htmlDecorators = {};
/**
* Gets or initializes an array of HTML decorators for a specific type
*
* @param {Symbol|string} [type] - The decorator type to get decorators for. If not provided,
* defaults to NON_STREAM_HTML_DECORATOR
* @returns {Function[]} An array of decorator functions for the specified type
* @example
* const decorators = getHtmlDecoratorsForType(myDecoratorType);
* // Returns existing decorators array or initializes a new one
*/
function getHtmlDecoratorsForType(type) {
return (htmlDecorators[type ?? NON_STREAM_HTML_DECORATOR] ||= []);
}
/**
* Registers a new HTML decorator function for a specific type of decoration.
*
* @param {Function} decorator - The decorator function to register. It receives two parameters:
* 1. element: HTMLElement to be decorated
* 2. options: Object containing decoration options
* The decorator can optionally return a cleanup function.
* @param {Symbol|string} [type=NON_STREAM_HTML_DECORATOR] - The type of decorator to register.
* When not provided, defaults to NON_STREAM_HTML_DECORATOR (non-stream).
* @returns {void}
* @example
* registerHtmlDecorator((element, options) => {
* element.classList.add('decorated');
* return () => element.classList.remove('decorated'); // Cleanup function
* });
*/
export function registerHtmlDecorator(decorator, type) {
getHtmlDecoratorsForType(type).push(decorator);
}
/**
* Applies registered HTML decorators to a DOM element and returns their cleanup functions.
*
* @param {HTMLElement} element - The DOM element to apply decorators to
* @param {Object} options - Options object passed to each decorator function
* @param {Symbol|string} [type=NON_STREAM_HTML_DECORATOR] - The type of decorators to apply.
* When not provided, defaults to NON_STREAM_HTML_DECORATOR (non-stream).
* @returns {Function[]} Array of cleanup functions from decorators that returned them.
* Each function, when called, will undo the decorator's effects.
* @example
* const cleanupFunctions = applyHtmlDecorators(
* document.querySelector('.content'),
* { highlight: true }
* );
*
* // To clean up all decorations:
* cleanupFunctions.forEach(cleanup => cleanup());
*/
export function applyHtmlDecorators(element, options, type) {
return getHtmlDecoratorsForType(type)
.map((decorator) => {
return decorator(element, options);
})
.filter((fn) => typeof fn === "function");
}
/**
* Clears all registered HTML decorators
*
* USE ONLY FOR TESTING PURPOSES
*
* @returns {void}
*/
export function resetHtmlDecorators() {
if (isTesting()) {
htmlDecorators = {};
}
}
/**
* Reactively renders cooked HTML with decorations applied.
*/
@@ -128,11 +207,21 @@ class DecorateHtmlHelper {
this.#context = context;
}
/**
* Renders a Glimmer component into a specified target HTML element with provided data.
* Handles deprecation warnings if invalid parameters are passed.
*
* @param {Element} targetElement The HTML element where the Glimmer component should be rendered. Must be a valid DOM element.
* @param {Object} component The Glimmer component object to render. Should not have a name of "factory" to avoid deprecation warnings.
* @param {Object} data The data to be passed to the Glimmer component for rendering.
* @param {Object} [opts={}] Additional options for rendering. Supports an `append` boolean property that defaults to `true` to determine if the content should be appended to the target element.
* @return {void}
*/
renderGlimmer(targetElement, component, data, opts = {}) {
if (!(targetElement instanceof Element)) {
deprecated(
"Invalid `targetElement` passed to `helper.renderGlimmer` while using `api.decorateCookedElement` with the Glimmer Post Stream. `targetElement` must be a valid HTML element. This call has been ignored to prevent errors.",
POST_STREAM_DEPRECATION_OPTIONS
WIDGET_DECOMMISSION_OPTIONS
);
return;
@@ -141,7 +230,7 @@ class DecorateHtmlHelper {
if (component.name === "factory") {
deprecated(
"Invalid `component` passed to `helper.renderGlimmer` while using `api.decorateCookedElement` with the Glimmer Post Stream. `component` must be a valid Glimmer component. If using a template compiled via ember-cli-htmlbars, replace it with the `<template>...</template>` syntax. This call has been ignored to prevent errors.",
POST_STREAM_DEPRECATION_OPTIONS
WIDGET_DECOMMISSION_OPTIONS
);
return;
@@ -168,24 +257,11 @@ class DecorateHtmlHelper {
return this.model;
}
// TODO (glimmer-post-stream): remove this when we remove the legacy post stream code
get widget() {
deprecated(
"Accessing `helper.widget` is not supported when using `api.decorateCookedElement` with the Glimmer Post Stream and can yield unexpected results.",
POST_STREAM_DEPRECATION_OPTIONS
"Using `helper.widget` has been decommissioned. See https://meta.discourse.org/t/372063/1",
WIDGET_DECOMMISSION_OPTIONS
);
const attrs = this.model;
return {
get attrs() {
return attrs;
},
scheduleRerender() {
// This is a no-op when using the new glimmer components.
// The component will rerender automatically when the model changes.
},
};
}
teardown() {
@@ -7,10 +7,9 @@ import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import DecoratedHtml from "./decorated-html";
import DecoratedHtml, { applyHtmlDecorators } from "./decorated-html";
export default class DiscourseBanner extends Component {
@service appEvents;
@service currentUser;
@service keyValueStore;
@service site;
@@ -50,11 +49,7 @@ export default class DiscourseBanner extends Component {
@bind
decorateContent(element, helper) {
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
element,
helper
);
applyHtmlDecorators(element, helper);
}
@action
@@ -349,9 +349,6 @@ export default class InvitePanel extends Component {
.then(() => {
model.setProperties({ saving: false, finished: true });
this.inviteModel.reload().then(() => {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
this.toasts.success({
data: { message: this.successMessage(this.invitee) },
});
@@ -368,9 +365,6 @@ export default class InvitePanel extends Component {
this.get("inviteModel.details.allowed_users").pushObject(
EmberObject.create(result.user)
);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { force: true });
this.toasts.success({
data: { message: this.successMessage(result) },
});
@@ -31,7 +31,6 @@ export default class History extends Component {
@service site;
@service currentUser;
@service siteSettings;
@service appEvents;
@tracked loading;
@tracked postRevision;
@@ -193,10 +192,7 @@ export default class History extends Component {
return;
}
postStream.triggerChangedPost(postId, this.args.model).then(() =>
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: postId })
);
postStream.triggerChangedPost(postId, this.args.model);
} finally {
this.loading = false;
this.initialLoad = false;
@@ -4,10 +4,9 @@ import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { waitForPromise } from "@ember/test-waiters";
import { create } from "virtual-dom";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import { iconNode } from "discourse/lib/icon-library";
import { iconElement } from "discourse/lib/icon-library";
import { i18n } from "discourse-i18n";
export default class JsonSchemaEditorModal extends Component {
@@ -116,6 +115,6 @@ class DiscourseJsonSchemaEditorIconlib {
if (!this.mapping[key]) {
return;
}
return create(iconNode(this.mapping[key]));
return iconElement(this.mapping[key]);
}
}
@@ -1,282 +1,27 @@
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
/* eslint-disable ember/no-classic-components */
import ArrayProxy from "@ember/array/proxy";
import Component from "@ember/component";
import { cancel, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service";
import { camelize } from "@ember/string";
import { diff, patch } from "virtual-dom";
import DirtyKeys from "discourse/lib/dirty-keys";
import { getRegister } from "discourse/lib/get-owner";
import { WidgetClickHook } from "discourse/widgets/hooks";
import {
queryRegistry,
traverseCustomWidgets,
warnWidgetsDeprecation,
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
export {
warnWidgetsDecommissioned as addWidgetCleanCallback,
warnWidgetsDecommissioned as removeWidgetCleanCallback,
warnWidgetsDecommissioned as resetWidgetCleanCallbacks,
} from "discourse/widgets/widget";
let _cleanCallbacks = {};
export function addWidgetCleanCallback(widgetName, fn) {
_cleanCallbacks[widgetName] = _cleanCallbacks[widgetName] || [];
_cleanCallbacks[widgetName].push(fn);
}
export function removeWidgetCleanCallback(widgetName, fn) {
const callbacks = _cleanCallbacks[widgetName];
if (!callbacks) {
return;
}
const index = callbacks.indexOf(fn);
if (index === -1) {
return;
}
callbacks.splice(index, 1);
}
export function resetWidgetCleanCallbacks() {
_cleanCallbacks = {};
}
export default class MountWidget extends Component {
@service siteSettings;
dirtyKeys = null;
_tree = null;
_rootNode = null;
_timeout = null;
_widgetClass = null;
_renderCallback = null;
_childEvents = null;
_dispatched = null;
init() {
super.init(...arguments);
const name = this.widget;
if (this.isDeactivated) {
warnWidgetsDeprecation(
`Widgets are deactivated and won't be rendered. Your site may not work properly. Affected widget: ${name}.`,
true
);
return;
} else {
warnWidgetsDeprecation(
`The \`MountWidget\` component is deprecated and will soon stop working. Use Glimmer components instead. Affected widget: ${name}.`
);
}
if (name === "post-cooked") {
throw [
"Cannot use <MountWidget /> with `post-cooked`.",
"It's a special-case that needs to be wrapped in another widget.",
"For example:",
" createWidget('test-widget', {",
" html(attrs) {",
" return [",
" new PostCooked(attrs, new DecoratorHelper(this), this.currentUser),",
" ];",
" },",
" });",
].join("\n");
}
this.register = getRegister(this);
this._widgetClass =
queryRegistry(name) || this.register.lookupFactory(`widget:${name}`);
if (this._widgetClass?.class) {
this._widgetClass = this._widgetClass.class;
}
if (!this._widgetClass) {
// eslint-disable-next-line no-console
console.error(`Error: Could not find widget: ${name}`);
}
this._childEvents = [];
this._connected = [];
this._childComponents = ArrayProxy.create({ content: [] });
this._dispatched = [];
this.dirtyKeys = new DirtyKeys(name);
}
get isDeactivated() {
return this.siteSettings.deactivate_widgets_rendering;
}
didInsertElement() {
super.didInsertElement(...arguments);
if (this.isDeactivated) {
return;
}
WidgetClickHook.setupDocumentCallback();
this._rootNode = document.createElement("div");
this.element.appendChild(this._rootNode);
this._timeout = scheduleOnce("render", this, this.rerenderWidget);
}
willClearRender() {
if (this.isDeactivated) {
return;
}
super.willClearRender(...arguments);
const callbacks = _cleanCallbacks[this.widget];
if (callbacks) {
callbacks.forEach((cb) => cb(this._tree));
}
this._connected.forEach((v) => v.destroy());
this._connected.length = 0;
traverseCustomWidgets(this._tree, (w) => w.destroy());
this._rootNode = patch(this._rootNode, diff(this._tree, null));
this._tree = null;
}
willDestroyElement() {
super.willDestroyElement(...arguments);
if (this.isDeactivated) {
return;
}
this._dispatched.forEach((evt) => {
const [eventName, caller] = evt;
this.appEvents.off(eventName, this, caller);
});
cancel(this._timeout);
}
afterRender() {}
beforePatch() {}
afterPatch() {}
eventDispatched(eventName, key, refreshArg) {
if (this.isDeactivated) {
return;
}
key = typeof key === "function" ? key(refreshArg) : key;
const onRefresh = camelize(eventName.replace(/:/, "-"));
this.dirtyKeys.keyDirty(key, { onRefresh, refreshArg });
this.queueRerender();
}
dispatch(eventName, key) {
if (this.isDeactivated) {
return;
}
this._childEvents.push(eventName);
const caller = (refreshArg) =>
this.eventDispatched(eventName, key, refreshArg);
this._dispatched.push([eventName, caller]);
this.appEvents.on(eventName, this, caller);
}
queueRerender(callback) {
if (this.isDeactivated) {
return;
}
if (callback && !this._renderCallback) {
this._renderCallback = callback;
}
scheduleOnce("render", this, this.rerenderWidget);
}
buildArgs() {}
rerenderWidget() {
if (this.isDeactivated) {
return;
}
cancel(this._timeout);
if (this._rootNode) {
if (!this._widgetClass) {
return;
}
const t0 = Date.now();
const args = this.args || this.buildArgs();
const opts = {
model: this.model,
dirtyKeys: this.dirtyKeys,
};
const newTree = new this._widgetClass(args, this.register, opts);
newTree._rerenderable = this;
newTree._emberView = this;
const patches = diff(this._tree || this._rootNode, newTree);
traverseCustomWidgets(this._tree, (w) => w.willRerenderWidget());
this.beforePatch();
this._rootNode = patch(this._rootNode, patches);
this.afterPatch();
this._tree = newTree;
traverseCustomWidgets(newTree, (w) => w.didRenderWidget());
if (this._renderCallback) {
this._renderCallback();
this._renderCallback = null;
}
this.afterRender();
this.dirtyKeys.renderedKey("*");
if (this.profileWidget) {
// eslint-disable-next-line no-console
console.log(Date.now() - t0);
}
}
}
mountChildComponent(info) {
if (this.isDeactivated) {
return;
}
this._childComponents.pushObject(info);
}
unmountChildComponent(info) {
if (this.isDeactivated) {
return;
}
this._childComponents.removeObject(info);
}
didUpdateAttrs() {
super.didUpdateAttrs(...arguments);
if (this.isDeactivated) {
return;
}
this.queueRerender();
warnWidgetsDecommissioned();
}
<template>
{{#unless this.isDeactivated}}
{{#each this._childComponents as |info|}}
{{#in-element info.element insertBefore=null}}
<info.component
@data={{info.data}}
@setWrapperElementAttrs={{info.setWrapperElementAttrs}}
/>
{{/in-element}}
{{/each}}
{{/unless}}
{{! No-op. The widget rendering system was decommissioned }}
</template>
}
@@ -1,5 +1,33 @@
import Component from "@glimmer/component";
import { actionDescriptionHtml } from "discourse/widgets/post-small-action";
import { htmlSafe } from "@ember/template";
import {
customGroupActionCodes,
GROUP_ACTION_CODES,
} from "discourse/components/post/small-action";
import { autoUpdatingRelativeAge } from "discourse/lib/formatter";
import { userPath } from "discourse/lib/url";
import { i18n } from "discourse-i18n";
export function actionDescriptionHtml(actionCode, createdAt, username, path) {
const when = createdAt
? autoUpdatingRelativeAge(new Date(createdAt), {
format: "medium-with-ago-and-on",
})
: "";
let who = "";
if (username) {
if (
GROUP_ACTION_CODES.includes(actionCode) ||
customGroupActionCodes.includes(actionCode)
) {
who = `<a class="mention-group" href="/g/${username}">@${username}</a>`;
} else {
who = `<a class="mention" href="${userPath(username)}">@${username}</a>`;
}
}
return htmlSafe(i18n(`action_codes.${actionCode}`, { who, when, path }));
}
export default class PostActionDescription extends Component {
get description() {
@@ -5,7 +5,9 @@ import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DecoratedHtml from "discourse/components/decorated-html";
import DecoratedHtml, {
applyHtmlDecorators,
} from "discourse/components/decorated-html";
import ExpandPost from "discourse/components/expand-post";
import PostListItemDetails from "discourse/components/post-list/item/details";
import avatar from "discourse/helpers/avatar";
@@ -18,7 +20,6 @@ import { or } from "discourse/truth-helpers";
export default class PostListItem extends Component {
@service site;
@service currentUser;
@service appEvents;
get moderatorActionClass() {
return this.args.post.post_type === this.site.post_types.moderator_action
@@ -104,11 +105,7 @@ export default class PostListItem extends Component {
@bind
decoratePostContent(element, helper) {
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
element,
helper
);
applyHtmlDecorators(element, helper);
}
<template>
@@ -4,7 +4,10 @@ import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { TrackedMap } from "@ember-compat/tracked-built-ins";
import curryComponent from "ember-curry-component";
import DecoratedHtml from "discourse/components/decorated-html";
import DecoratedHtml, {
applyHtmlDecorators,
NON_STREAM_HTML_DECORATOR,
} from "discourse/components/decorated-html";
import lazyHash from "discourse/helpers/lazy-hash";
import { bind } from "discourse/lib/decorators";
import { isRailsTesting, isTesting } from "discourse/lib/environment";
@@ -27,8 +30,9 @@ const POST_COOKED_DECORATORS = [
decorateMentions,
];
export const STREAM_HTML_DECORATOR = Symbol("stream-html-decorator");
export default class PostCookedHtml extends Component {
@service appEvents;
@service currentUser;
#pendingDecoratorCleanup = [];
@@ -131,13 +135,12 @@ export default class PostCookedHtml extends Component {
}
});
this.appEvents.trigger(
this.isStreamElement
? "decorate-post-cooked-element"
: "decorate-non-stream-cooked-element",
element,
helper
);
const cleanUpFns = [
...applyHtmlDecorators(element, helper, NON_STREAM_HTML_DECORATOR),
...applyHtmlDecorators(element, helper, STREAM_HTML_DECORATOR),
];
this.#pendingDecoratorCleanup.push(...cleanUpFns);
}
get className() {
@@ -4,20 +4,15 @@ import DTooltip from "discourse/float-kit/components/d-tooltip";
import { i18n } from "discourse-i18n";
export default class PostMetaDataLanguage extends Component {
// TODO (glimmer-post-stream) once we switch to glimmer, we can remove `this.args.data.x` from the following 2 getters
@service languageNameLookup;
get language() {
const lang = this.args.data?.language || this.args.post?.language;
const lang = this.args.post?.language;
return this.languageNameLookup.getLanguageName(lang);
}
get outdated() {
return (
this.args.data?.localization_outdated ||
this.args.post?.localization_outdated
);
return this.args.post?.localization_outdated;
}
get tooltip() {
@@ -8,7 +8,6 @@ import icon from "discourse/helpers/d-icon";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { i18n } from "discourse-i18n";
// TODO (glimmer-post-stream) needs tests
export default class PostNoticeCustom extends Component {
@tracked createdByAnchorElement;
@@ -4,7 +4,6 @@ import emoji from "discourse/helpers/emoji";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { i18n } from "discourse-i18n";
// TODO (glimmer-post-stream) needs tests
export default class PostNoticeNewUser extends Component {
@service siteSettings;
@@ -16,7 +16,6 @@ import { userPath } from "discourse/lib/url";
import { i18n } from "discourse-i18n";
import PostA11yHeading from "./a11y-heading";
// TODO (glimmer-post-stream) remove the export after removing the legacy widget code
export const GROUP_ACTION_CODES = ["invited_group", "removed_group"];
export const customGroupActionCodes = [];
@@ -1,7 +1,8 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DecoratedHtml from "discourse/components/decorated-html";
import DecoratedHtml, {
applyHtmlDecorators,
} from "discourse/components/decorated-html";
import PluginOutlet from "discourse/components/plugin-outlet";
import ReviewablePostEdits from "discourse/components/reviewable-post-edits";
import ReviewableCreatedBy from "discourse/components/reviewable-refresh/created-by";
@@ -12,8 +13,6 @@ import highlightHTML from "discourse/lib/highlight-html";
import { i18n } from "discourse-i18n";
export default class ReviewablePost extends Component {
@service appEvents;
get metaLabel() {
return this.args.metaLabel || i18n("review.posted_in");
}
@@ -46,11 +45,7 @@ export default class ReviewablePost extends Component {
}
}
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
element,
helper
);
applyHtmlDecorators(element, helper);
}
<template>
@@ -5,7 +5,9 @@ import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import DecoratedHtml from "discourse/components/decorated-html";
import DecoratedHtml, {
applyHtmlDecorators,
} from "discourse/components/decorated-html";
import InterpolatedTranslation from "discourse/components/interpolated-translation";
import ReviewableFlagReason from "discourse/components/reviewable-refresh/flag-reason";
import ReviewableNoteForm from "discourse/components/reviewable-refresh/note-form";
@@ -27,7 +29,6 @@ import { i18n } from "discourse-i18n";
* @component ReviewableTimeline
*/
export default class ReviewableTimeline extends Component {
@service appEvents;
@service currentUser;
@service store;
@@ -219,11 +220,7 @@ export default class ReviewableTimeline extends Component {
@bind
decorate(element, helper) {
this.appEvents.trigger(
"decorate-non-stream-cooked-element",
element,
helper
);
applyHtmlDecorators(element, helper);
}
<template>
@@ -1,395 +0,0 @@
import { next, schedule, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service";
import MountWidget from "discourse/components/mount-widget";
import discourseDebounce from "discourse/lib/debounce";
import { bind } from "discourse/lib/decorators";
import domUtils from "discourse/lib/dom-utils";
import offsetCalculator from "discourse/lib/offset-calculator";
import DiscourseURL from "discourse/lib/url";
import { cloak, uncloak } from "discourse/widgets/post-stream";
const DEBOUNCE_DELAY = 50;
function findTopView(posts, viewportTop, postsWrapperTop, min, max) {
if (max < min) {
return min;
}
while (max > min) {
const mid = Math.floor((min + max) / 2);
const post = posts.item(mid);
const viewBottom =
domUtils.offset(post).top - postsWrapperTop + post.clientHeight;
if (viewBottom > viewportTop) {
max = mid - 1;
} else {
min = mid + 1;
}
}
return min;
}
export default class ScrollingPostStream extends MountWidget {
@service screenTrack;
@service site;
widget = "post-stream";
_topVisible = null;
_bottomVisible = null;
_currentPostObj = null;
_currentVisible = null;
_currentPercent = null;
buildArgs() {
return this.getProperties(
"posts",
"canCreatePost",
"filteredPostsCount",
"filteringRepliesToPostNumber",
"multiSelect",
"gaps",
"selectedQuery",
"selectedPostsCount",
"searchService",
"showReadIndicator",
"streamFilters",
"lastReadPostNumber",
"highestPostNumber",
"topicPageQueryParams"
);
}
scrolled() {
if (this.isDestroyed || this.isDestroying) {
return;
}
if (document.webkitFullscreenElement || document.fullscreenElement) {
return;
}
const windowHeight = window.innerHeight;
const slack = Math.round(windowHeight * 5);
const onscreen = [];
const nearby = [];
const windowTop = document.scrollingElement.scrollTop;
const postsWrapperTop = domUtils.offset(
document.querySelector(".posts-wrapper")
).top;
const postsNodes = this.element.querySelectorAll(
".onscreen-post, .cloaked-post"
);
const viewportTop = windowTop - slack;
const topView = findTopView(
postsNodes,
viewportTop,
postsWrapperTop,
0,
postsNodes.length - 1
);
let windowBottom = windowTop + windowHeight;
let viewportBottom = windowBottom + slack;
const bodyHeight = document.body.clientHeight;
if (windowBottom > bodyHeight) {
windowBottom = bodyHeight;
}
if (viewportBottom > bodyHeight) {
viewportBottom = bodyHeight;
}
let currentPost = null;
let percent = null;
const offset = offsetCalculator();
const topCheck = Math.ceil(windowTop + offset + 5);
// uncomment to debug the eyeline
/*
let $eyeline = $('.debug-eyeline');
if ($eyeline.length === 0) {
$('body').prepend('<div class="debug-eyeline"></div>');
$eyeline = $('.debug-eyeline');
}
$eyeline.css({ height: '5px', width: '100%', backgroundColor: 'blue', position: 'absolute', top: `${topCheck}px`, zIndex: 999999 });
*/
let allAbove = true;
let bottomView = topView;
let lastBottom = 0;
while (bottomView < postsNodes.length) {
const post = postsNodes.item(bottomView);
if (!post) {
break;
}
const viewTop = domUtils.offset(post).top;
const postHeight = post.clientHeight;
const viewBottom = Math.ceil(viewTop + postHeight);
allAbove = allAbove && viewTop < topCheck;
if (viewTop > viewportBottom) {
break;
}
if (viewBottom >= windowTop && viewTop <= windowBottom) {
onscreen.push(bottomView);
}
if (
currentPost === null &&
((viewTop <= topCheck && viewBottom >= topCheck) ||
(lastBottom <= topCheck && viewTop >= topCheck))
) {
percent = (topCheck - viewTop) / postHeight;
currentPost = bottomView;
}
lastBottom = viewBottom;
nearby.push(bottomView);
bottomView++;
}
if (allAbove) {
if (percent === null) {
percent = 1.0;
}
if (currentPost === null) {
currentPost = bottomView - 1;
}
}
const posts = this.posts;
const refresh = (cb) => this.queueRerender(cb);
if (onscreen.length) {
const first = posts.objectAt(onscreen[0]);
if (this._topVisible !== first) {
this._topVisible = first;
const elemId = postsNodes.item(onscreen[0]).id;
const topRefresh = () => {
refresh(() => {
const refreshedElem = document.getElementById(elemId);
if (!refreshedElem) {
return;
}
// The getOffsetTop function calculates the total offset distance of
// an element from the top of the document. Unlike element.offsetTop
// which only returns the offset relative to its nearest positioned
// ancestor, this function recursively accumulates the offsetTop
// of an element and all of its offset parents (ancestors).
// This ensures the total distance is measured from the very top of
// the document, accounting for any nested elements and their
// respective offsets.
const getOffsetTop = (element) => {
if (!element) {
return 0;
}
return element.offsetTop + getOffsetTop(element.offsetParent);
};
window.scrollTo({
top: getOffsetTop(refreshedElem) - offsetCalculator(),
});
// This seems weird, but somewhat infrequently a rerender
// will cause the browser to scroll to the top of the document
// in Chrome. This makes sure the scroll works correctly if that
// happens.
schedule("afterRender", () => {
window.scrollTo({
top: getOffsetTop(refreshedElem) - offsetCalculator(),
});
});
});
};
this.topVisibleChanged({
post: first,
refresh: topRefresh,
});
}
const last = posts.objectAt(onscreen[onscreen.length - 1]);
if (this._bottomVisible !== last) {
this._bottomVisible = last;
this.bottomVisibleChanged({ post: last, refresh });
}
const currentPostObj = posts.objectAt(currentPost);
const changedPost = this._currentPostObj !== currentPostObj;
if (changedPost) {
this._currentPostObj = currentPostObj;
this.currentPostChanged({ post: currentPostObj });
}
if (percent !== null) {
percent = Math.max(0.0, Math.min(1.0, percent));
if (changedPost || this._currentPercent !== percent) {
this._currentPercent = percent;
this.currentPostScrolled({ percent });
}
}
} else {
this._topVisible = null;
this._bottomVisible = null;
this._currentPostObj = null;
this._currentPercent = null;
}
const onscreenPostNumbers = new Set();
const readPostNumbers = new Set();
const newPrev = new Set();
nearby.forEach((idx) => {
const post = posts.objectAt(idx);
this._previouslyNearby.delete(post.post_number);
if (onscreen.includes(idx)) {
onscreenPostNumbers.add(post.post_number);
if (post.read) {
readPostNumbers.add(post.post_number);
}
}
newPrev.add(post.post_number, post);
uncloak(post, this);
});
Object.values(this._previouslyNearby).forEach((node) => cloak(node, this));
this._previouslyNearby = newPrev;
this.screenTrack.setOnscreen(onscreenPostNumbers, readPostNumbers);
}
_scrollTriggered() {
scheduleOnce("afterRender", this, this.scrolled);
}
_posted(staged) {
this.queueRerender(() => {
if (staged) {
const postNumber = staged.post_number;
DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true });
}
});
}
_refresh(args) {
if (args) {
if (args.id) {
this.dirtyKeys.keyDirty(`post-${args.id}`);
if (args.refreshLikes) {
this.dirtyKeys.keyDirty(`post-menu-${args.id}`, {
onRefresh: "refreshLikes",
});
}
if (args.refreshReaders) {
this.dirtyKeys.keyDirty(`post-menu-${args.id}`, {
onRefresh: "refreshReaders",
});
}
} else if (args.force) {
this.dirtyKeys.forceAll();
}
}
this.queueRerender();
this._scrollTriggered();
}
@bind
_debouncedScroll() {
discourseDebounce(this, this._scrollTriggered, DEBOUNCE_DELAY);
}
didInsertElement() {
super.didInsertElement(...arguments);
this._previouslyNearby = new Set();
this.appEvents.on("post-stream:refresh", this, "_debouncedScroll");
const opts = {
passive: true,
};
document.addEventListener("touchmove", this._debouncedScroll, opts);
window.addEventListener("scroll", this._debouncedScroll, opts);
if (this.site.useGlimmerPostStream) {
next(() => this._scrollTriggered());
} else {
this._scrollTriggered();
}
this.appEvents.on("post-stream:posted", this, "_posted");
this.element.addEventListener(
"mouseenter",
this._handleWidgetButtonHoverState,
true
);
this.element.addEventListener(
"mouseleave",
this._removeWidgetButtonHoverState,
true
);
this.appEvents.on("post-stream:refresh", this, "_refresh");
// restore scroll position on browsers with aggressive BFCaches (like Safari)
window.onpageshow = function (event) {
if (event.persisted) {
DiscourseURL.routeTo(this.location.pathname);
}
};
}
willDestroyElement() {
super.willDestroyElement(...arguments);
document.removeEventListener("touchmove", this._debouncedScroll);
window.removeEventListener("scroll", this._debouncedScroll);
this.appEvents.off("post-stream:refresh", this, "_debouncedScroll");
this.element.removeEventListener(
"mouseenter",
this._handleWidgetButtonHoverState
);
this.element.removeEventListener(
"mouseleave",
this._removeWidgetButtonHoverState
);
this.appEvents.off("post-stream:refresh", this, "_refresh");
this.appEvents.off("post-stream:posted", this, "_posted");
}
didUpdateAttrs() {
super.didUpdateAttrs(...arguments);
this._refresh({ force: true });
}
_handleWidgetButtonHoverState(event) {
if (event.target.classList.contains("widget-button")) {
document
.querySelectorAll("button.widget-button")
.forEach((widgetButton) => {
widgetButton.classList.remove("d-hover");
});
event.target.classList.add("d-hover");
}
}
_removeWidgetButtonHoverState() {
document.querySelectorAll("button.widget-button").forEach((button) => {
button.classList.remove("d-hover");
});
}
}
@@ -334,13 +334,6 @@ export default class SearchMenu extends Component {
// we ensure the current search term is the one used
// when starting the query
if (results) {
if (this.searchContext) {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", {
force: true,
});
}
this.search.noResults = results.resultTypes.length === 0;
this.search.results = results;
}
@@ -8,6 +8,7 @@ import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import { actionDescriptionHtml } from "discourse/components/post-action-description";
import TopicAdminMenu from "discourse/components/topic-admin-menu";
import UserTip from "discourse/components/user-tip";
import ageWithTooltip from "discourse/helpers/age-with-tooltip";
@@ -21,7 +22,6 @@ import domUtils from "discourse/lib/dom-utils";
import { headerOffset } from "discourse/lib/offset-calculator";
import TopicNotificationsButton from "discourse/select-kit/components/topic-notifications-button";
import { and, not, or } from "discourse/truth-helpers";
import { actionDescriptionHtml } from "discourse/widgets/post-small-action";
import { i18n } from "discourse-i18n";
import BackButton from "./back-button";
import Scroller from "./scroller";
@@ -92,8 +92,6 @@ export default class TopicTimelineScrollArea extends Component {
this.appEvents.on("composer:resized", this.calculatePosition);
this.appEvents.on("composer:closed", this.calculatePosition);
this.appEvents.on("composer:preview-toggled", this.calculatePosition);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.on("post-stream:posted", this.calculatePosition);
}
this.intersectionObserver = new IntersectionObserver((entries) => {
@@ -133,8 +131,6 @@ export default class TopicTimelineScrollArea extends Component {
this.appEvents.off("composer:closed", this.calculatePosition);
this.appEvents.off("composer:preview-toggled", this.calculatePosition);
this.appEvents.off("topic:current-post-scrolled", this.postScrolled);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.off("post-stream:posted", this.calculatePosition);
}
}
@@ -7,6 +7,7 @@ import { classNameBindings, tagName } from "@ember-decorators/component";
import DButton from "discourse/components/d-button";
import ExpandPost from "discourse/components/expand-post";
import PluginOutlet from "discourse/components/plugin-outlet";
import { actionDescriptionHtml } from "discourse/components/post-action-description";
import TopicStatus from "discourse/components/topic-status";
import avatar from "discourse/helpers/avatar";
import categoryLink from "discourse/helpers/category-link";
@@ -19,7 +20,20 @@ import discourseComputed from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
import { userPath } from "discourse/lib/url";
import { or } from "discourse/truth-helpers";
import { actionDescription } from "discourse/widgets/post-small-action";
function actionDescription(actionCode, createdAt, username, path = null) {
return computed(actionCode, createdAt, function () {
const ac = this.get(actionCode);
if (ac) {
return actionDescriptionHtml(
ac,
this.get(createdAt),
this.get(username),
path ? this.get(path) : null
);
}
});
}
@tagName("li")
@classNameBindings(
+50 -137
View File
@@ -10,10 +10,6 @@ import { observes } from "@ember-decorators/object";
import BufferedProxy from "ember-buffered-proxy/proxy";
import { Promise } from "rsvp";
import DEditorOriginalTranslationPreview from "discourse/components/d-editor-original-translation-preview";
import {
CLOSE_INITIATED_BY_BUTTON,
CLOSE_INITIATED_BY_ESC,
} from "discourse/components/d-modal";
import BookmarkModal from "discourse/components/modal/bookmark";
import ChangePostNoticeModal from "discourse/components/modal/change-post-notice";
import ConvertToPublicTopicModal from "discourse/components/modal/convert-to-public-topic";
@@ -118,11 +114,9 @@ export default class TopicController extends Controller {
super.init(...arguments);
this.appEvents.on("post:show-revision", this, "_showRevision");
this.appEvents.on("post:created", this, () => {
this._removeDeleteOnOwnerReplyBookmarks();
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { force: true });
});
this.appEvents.on("post:created", this, () =>
this._removeDeleteOnOwnerReplyBookmarks()
);
}
willDestroy() {
@@ -175,21 +169,6 @@ export default class TopicController extends Controller {
return mobileView && postsCount > 3;
}
// TODO (glimmer-post-stream) this method is not used in the Glimmer Post Stream
@discourseComputed(
"model.postStream.posts",
"model.postStream.postsWithPlaceholders"
)
postsToRender(posts, postsWithPlaceholders) {
return this.capabilities.isAndroid ? posts : postsWithPlaceholders;
}
// TODO (glimmer-post-stream) is this still used?
@discourseComputed("model.postStream.loadingFilter")
androidLoading(loading) {
return this.capabilities.isAndroid && loading;
}
@discourseComputed("model")
pmPath(topic) {
return this.currentUser && this.currentUser.pmPath(topic);
@@ -317,11 +296,6 @@ export default class TopicController extends Controller {
}
}
_forceRefreshPostStream() {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { force: true });
}
_updateSelectedPostIds(postIds) {
const smallActionsPostIds = this._smallActionPostIds();
this.selectedPostIds = Array.from(
@@ -330,7 +304,6 @@ export default class TopicController extends Controller {
...postIds.filter((postId) => !smallActionsPostIds.has(postId)),
])
);
this._forceRefreshPostStream();
}
_smallActionPostIds() {
@@ -468,21 +441,18 @@ export default class TopicController extends Controller {
(postId) => !smallActionsPostIds.has(postId)
),
];
this._forceRefreshPostStream();
}
@action
deselectAll(event) {
event?.preventDefault();
this.selectedPostIds = [];
this._forceRefreshPostStream();
}
@action
toggleMultiSelect(event) {
event?.preventDefault();
this.toggleProperty("multiSelect");
this._forceRefreshPostStream();
}
@action
@@ -650,7 +620,6 @@ export default class TopicController extends Controller {
postStream.get("canAppendMore")
) {
await postStream.appendMore();
// TODO (glimmer-post-stream) the Glimmer Post stream doesn't pass a refresh function
refresh?.();
}
}
@@ -847,22 +816,16 @@ export default class TopicController extends Controller {
}
const user = this.currentUser;
const refresh = () =>
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
const hasReplies = post.get("reply_count") > 0;
const loadedPosts = this.get("model.postStream.posts");
if (user.get("staff") && hasReplies) {
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
if (replies.length === 0) {
return post
.destroy(user, opts)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
return post.destroy(user, opts).catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
}
const buttons = [];
@@ -882,9 +845,7 @@ export default class TopicController extends Controller {
(p === post || directReplyIds.includes(p.id)) &&
p.setDeletedState(user)
);
Post.deleteMany([post.id, ...directReplyIds])
.then(refresh)
.catch(popupAjaxError);
Post.deleteMany([post.id, ...directReplyIds]).catch(popupAjaxError);
},
});
@@ -899,9 +860,9 @@ export default class TopicController extends Controller {
(p === post || replies.some((r) => r.id === p.id)) &&
p.setDeletedState(user)
);
Post.deleteMany([post.id, ...replies.map((r) => r.id)])
.then(refresh)
.catch(popupAjaxError);
Post.deleteMany([post.id, ...replies.map((r) => r.id)]).catch(
popupAjaxError
);
},
});
}
@@ -909,13 +870,10 @@ export default class TopicController extends Controller {
buttons.push({
label: i18n("post.controls.delete_replies.just_the_post"),
action: () => {
post
.destroy(user, opts)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
post.destroy(user, opts).catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
},
});
@@ -930,13 +888,10 @@ export default class TopicController extends Controller {
});
});
} else {
return post
.destroy(user, opts)
.then(refresh)
.catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
return post.destroy(user, opts).catch((error) => {
popupAjaxError(error);
post.undoDeleteState();
});
}
}
@@ -1069,15 +1024,7 @@ export default class TopicController extends Controller {
post
);
} else {
return this._toggleTopicLevelBookmark().then((changedIds) => {
if (!changedIds) {
return;
}
changedIds.forEach((id) =>
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id })
);
});
return this._toggleTopicLevelBookmark();
}
}
@@ -1163,7 +1110,6 @@ export default class TopicController extends Controller {
post.id,
...replyIds,
]);
this._forceRefreshPostStream();
});
}
@@ -1563,38 +1509,22 @@ export default class TopicController extends Controller {
}
_modifyPostBookmark(bookmark, post) {
this.modal
.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(bookmark),
afterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
post.createBookmark(savedData);
this.model.afterPostBookmarked(post, savedData);
return [post.id];
},
afterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
post.deleteBookmark(topicBookmarked);
},
this.modal.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(bookmark),
afterSave: (savedData) => {
this._syncBookmarks(savedData);
this.model.set("bookmarking", false);
post.createBookmark(savedData);
this.model.afterPostBookmarked(post, savedData);
return [post.id];
},
})
.then((closeData) => {
if (!closeData) {
return;
}
if (
closeData.closeWithoutSaving ||
closeData.initiatedBy === CLOSE_INITIATED_BY_ESC ||
closeData.initiatedBy === CLOSE_INITIATED_BY_BUTTON
) {
post.appEvents.trigger("post-stream:refresh", {
id: bookmark.bookmarkable_id,
});
}
});
afterDelete: (topicBookmarked, bookmarkId) => {
this.model.removeBookmark(bookmarkId);
post.deleteBookmark(topicBookmarked);
},
},
});
}
_syncBookmarks(data) {
@@ -1891,9 +1821,6 @@ export default class TopicController extends Controller {
@bind
onMessage(data) {
const topic = this.model;
const refresh = (args) =>
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", args);
if (isPresent(data.notification_level_change)) {
topic.set("details.notification_level", data.notification_level_change);
@@ -1923,48 +1850,39 @@ export default class TopicController extends Controller {
switch (data.type) {
case "acted":
postStream
.triggerChangedPost(data.id, data.updated_at, {
preserveCooked: true,
})
.then(() => refresh({ id: data.id, refreshLikes: true }));
postStream.triggerChangedPost(data.id, data.updated_at, {
preserveCooked: true,
});
break;
case "read": {
postStream
.triggerReadPost(data.id, data.readers_count)
.then(() => refresh({ id: data.id, refreshLikes: true }));
postStream.triggerReadPost(data.id, data.readers_count);
break;
}
case "liked":
case "unliked": {
postStream
.triggerLikedPost(data.id, data.likes_count, data.user_id, data.type)
.then(() => refresh({ id: data.id, refreshLikes: true }));
postStream.triggerLikedPost(
data.id,
data.likes_count,
data.user_id,
data.type
);
break;
}
case "revised":
case "rebaked": {
postStream
.triggerChangedPost(data.id, data.updated_at)
.then(() => refresh({ id: data.id }));
postStream.triggerChangedPost(data.id, data.updated_at);
break;
}
case "deleted": {
postStream
.triggerDeletedPost(data.id)
.then(() => refresh({ id: data.id }));
postStream.triggerDeletedPost(data.id);
break;
}
case "destroyed": {
postStream
.triggerDestroyedPost(data.id)
.then(() => refresh({ id: data.id }));
postStream.triggerDestroyedPost(data.id);
break;
}
case "recovered": {
postStream
.triggerRecoveredPost(data.id)
.then(() => refresh({ id: data.id }));
postStream.triggerRecoveredPost(data.id);
break;
}
case "created": {
@@ -1976,7 +1894,6 @@ export default class TopicController extends Controller {
return postStream
.triggerNewPostsInStream(postIds, { background: true })
.then(() => refresh())
.catch((e) => {
this._newPostsInStream = postIds.concat(this._newPostsInStream);
throw e;
@@ -2012,9 +1929,7 @@ export default class TopicController extends Controller {
}
if (updateStream) {
postStream
.triggerChangedTopicStats()
.then((firstPostId) => refresh({ id: firstPostId }));
postStream.triggerChangedTopicStats();
}
break;
}
@@ -2046,8 +1961,6 @@ export default class TopicController extends Controller {
postStream.get("posts").forEach((post) => {
if (!post.read && postNumbers.includes(post.post_number)) {
post.set("read", true);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: post.get("id") });
}
});
+1 -4
View File
@@ -1,9 +1,6 @@
import "discourse/lib/load-moment";
import jQuery from "jquery";
import virtualDom from "virtual-dom";
import widgetHelpers from "discourse-widget-hbs/helpers";
window.__widget_helpers = widgetHelpers;
import * as virtualDom from "discourse/widgets/virtual-dom";
// TODO: Eliminate this global
window.virtualDom = virtualDom;
@@ -1,5 +1,8 @@
import { renderIcon } from "discourse/lib/icon-library";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export function iconNode(id, params) {
return renderIcon("node", id, params);
}
export { iconNode } from "discourse/lib/icon-library";
+4 -34
View File
@@ -1,34 +1,4 @@
import { h } from "virtual-dom";
import { longDate, number, relativeAge } from "discourse/lib/formatter";
export function dateNode(dt) {
if (typeof dt === "string") {
dt = new Date(dt);
}
if (dt) {
const attributes = {
title: longDate(dt),
"data-time": dt.getTime(),
"data-format": "tiny",
};
return h("span.relative-date", { attributes }, relativeAge(dt));
}
}
export function numberNode(num, opts) {
opts = opts || {};
num = parseInt(num, 10);
if (isNaN(num)) {
num = 0;
}
const numString = num.toString();
const attributes = {};
const formatted = number(num);
if (formatted !== numString) {
attributes.title = numString;
}
return h("span.number", { className: opts.className, attributes }, formatted);
}
export {
warnWidgetsDecommissioned as dateNode,
warnWidgetsDecommissioned as numberNode,
} from "discourse/widgets/widget";
@@ -2,7 +2,7 @@ import { iconHTML } from "discourse/lib/icon-library";
import { withPluginApi } from "discourse/lib/plugin-api";
import { prefersReducedMotion } from "discourse/lib/utilities";
let _gifClickHandlers = {};
const eventedImageElements = new WeakSet();
function _pauseAnimation(img, opts = {}) {
let canvas = document.createElement("canvas");
@@ -35,15 +35,6 @@ function animatedImgs() {
export default {
initialize() {
withPluginApi((api) => {
function _cleanUp() {
Object.values(_gifClickHandlers || {}).forEach((handler) => {
handler.removeEventListener("click", _handleEvent);
handler.removeEventListener("load", _handleEvent);
});
_gifClickHandlers = {};
}
function _handleEvent(event) {
const img = event.target;
if (img && !img.previousSibling) {
@@ -58,39 +49,35 @@ export default {
return;
}
let images = post.querySelectorAll("img.animated:not(.onebox-avatar)");
let imageElements = post.querySelectorAll(
"img.animated:not(.onebox-avatar)"
);
images.forEach((img) => {
imageElements.forEach((imageElement) => {
// skip for edge case of multiple animated images in same block
if (img.parentNode.querySelectorAll("img").length > 1) {
if (imageElement.parentNode.querySelectorAll("img").length > 1) {
return;
}
if (_gifClickHandlers[img.src]) {
_gifClickHandlers[img.src].removeEventListener(
"click",
_handleEvent
);
_gifClickHandlers[img.src].removeEventListener(
"load",
_handleEvent
);
delete _gifClickHandlers[img.src];
if (eventedImageElements.has(imageElement)) {
imageElement.removeEventListener("click", _handleEvent);
imageElement.removeEventListener("load", _handleEvent);
eventedImageElements.delete(imageElement);
}
_gifClickHandlers[img.src] = img;
img.addEventListener("click", _handleEvent, false);
eventedImageElements.add(imageElement);
imageElement.addEventListener("click", _handleEvent, false);
if (prefersReducedMotion()) {
img.addEventListener("load", _handleEvent, false);
imageElement.addEventListener("load", _handleEvent, false);
}
const wrapper = document.createElement("div"),
overlay = document.createElement("div");
img.parentNode.insertBefore(wrapper, img);
imageElement.parentNode.insertBefore(wrapper, imageElement);
wrapper.classList.add("pausable-animated-image");
wrapper.appendChild(img);
wrapper.appendChild(imageElement);
overlay.classList.add("animated-image-overlay");
overlay.setAttribute("aria-hidden", "true");
@@ -104,8 +91,6 @@ export default {
onlyStream: true,
});
api.cleanupStream(_cleanUp);
// paused on load when prefers-reduced-motion is active, no need for blur/focus events
if (!prefersReducedMotion()) {
window.addEventListener("blur", this.blurEvent);
@@ -2,53 +2,38 @@ import { schedule } from "@ember/runloop";
import CodeblockButtons from "discourse/lib/codeblock-buttons";
import { withPluginApi } from "discourse/lib/plugin-api";
let _codeblockButtons = [];
export default {
initialize(owner) {
const site = owner.lookup("service:site");
const siteSettings = owner.lookup("service:site-settings");
withPluginApi((api) => {
function _cleanUp() {
_codeblockButtons.forEach((cb) => cb.cleanup());
_codeblockButtons.length = 0;
}
function _attachCommands(postElement, helper) {
if (!helper) {
return;
}
if (!siteSettings.show_copy_button_on_codeblocks) {
return;
}
const post = helper.getModel();
const cb = new CodeblockButtons({
site,
showFullscreen: true,
showCopy: true,
});
cb.attachToPost(post, postElement);
_codeblockButtons.push(cb);
// must be done after render so we can check the scroll width
// of the code blocks
schedule("afterRender", () => {
cb.attachToPost(post, postElement);
});
return cb.cleanup;
}
api.decorateCookedElement(
(postElement, helper) => {
// must be done after render so we can check the scroll width
// of the code blocks
schedule("afterRender", () => {
_attachCommands(postElement, helper);
});
},
{
if (siteSettings.show_copy_button_on_codeblocks) {
api.decorateCookedElement(_attachCommands, {
onlyStream: true,
}
);
api.cleanupStream(_cleanUp);
});
}
});
},
};
@@ -1,15 +1,15 @@
import { withPluginApi } from "discourse/lib/plugin-api";
let _showMoreClickPostsElements = [];
export function decorateGithubOneboxBody(element) {
const containers = element.querySelectorAll(
".onebox.githubcommit .show-more-container, .onebox.githubpullrequest .show-more-container, .onebox.githubissue .show-more-container"
);
if (containers.length) {
_showMoreClickPostsElements.push(element);
element.addEventListener("click", _handleClick, false);
// cleanup function to remove the event listener while cleaning up the decorations
return () => element.removeEventListener("click", _handleClick);
}
}
@@ -29,22 +29,10 @@ function _handleClick(event) {
return false;
}
function _cleanUp() {
(_showMoreClickPostsElements || []).forEach((element) => {
element.removeEventListener("click", _handleClick);
});
_showMoreClickPostsElements = [];
}
export default {
initialize() {
withPluginApi((api) => {
api.decorateCookedElement((element) => {
decorateGithubOneboxBody(element);
});
api.cleanupStream(_cleanUp);
api.decorateCookedElement(decorateGithubOneboxBody);
});
},
};
@@ -1,12 +1,11 @@
import { schedule } from "@ember/runloop";
import { create } from "virtual-dom";
import FullscreenTableModal from "discourse/components/modal/fullscreen-table";
import SpreadsheetEditor from "discourse/components/modal/spreadsheet-editor";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Columns from "discourse/lib/columns";
import highlightSyntax from "discourse/lib/highlight-syntax";
import { iconHTML, iconNode } from "discourse/lib/icon-library";
import { iconElement, iconHTML } from "discourse/lib/icon-library";
import { nativeLazyLoading } from "discourse/lib/lazy-load-images";
import lightbox from "discourse/lib/lightbox";
import { withPluginApi } from "discourse/lib/plugin-api";
@@ -119,9 +118,9 @@ export default {
}
if (props.icon) {
const icon = create(
iconNode(props.icon.name, { class: props.icon?.class })
);
const icon = iconElement(props.icon.name, {
class: props.icon?.class,
});
openPopupBtn.prepend(icon);
}
@@ -187,8 +186,7 @@ export default {
table.parentNode.setAttribute("data-table-index", index);
table.parentNode.classList.add("fullscreen-table-wrapper");
// TODO (glimmer-post-stream) in the Glimmer post stream we can check for post.can_edit instead
if (post.canEdit) {
if (post.can_edit) {
table.parentNode.classList.add("--editable");
buttonWrapper.append(tableEditorBtn);
tableEditorBtn.addEventListener(
@@ -224,21 +222,6 @@ export default {
});
}
function cleanupPopupBtns() {
const editTableBtn = document.querySelector(
".open-popup-link.btn-edit-table"
);
const expandTableBtn = document.querySelector(
".open-popup-link.btn-expand-table"
);
expandTableBtn?.removeEventListener(
"click",
generateFullScreenTableModal
);
editTableBtn?.removeEventListener("click", generateSpreadsheetModal);
}
api.decorateCookedElement(
(element, helper) => {
schedule("afterRender", () => {
@@ -251,8 +234,6 @@ export default {
id: "table-wrapper",
}
);
api.cleanupStream(cleanupPopupBtns);
});
},
};
+7 -41
View File
@@ -1,42 +1,8 @@
// FROM: https://github.com/Matt-Esch/virtual-dom
// License: MIT
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
function AttributeHook(namespace, value) {
if (!(this instanceof AttributeHook)) {
return new AttributeHook(namespace, value);
}
this.namespace = namespace;
this.value = value;
}
AttributeHook.prototype.hook = function (node, prop, prev) {
if (
prev &&
prev.type === "AttributeHook" &&
prev.value === this.value &&
prev.namespace === this.namespace
) {
return;
}
node.setAttributeNS(this.namespace, prop, this.value);
};
AttributeHook.prototype.unhook = function (node, prop, next) {
if (
next &&
next.type === "AttributeHook" &&
next.namespace === this.namespace
) {
return;
}
let colonPosition = prop.indexOf(":");
let localName = colonPosition > -1 ? prop.slice(colonPosition + 1) : prop;
node.removeAttributeNS(this.namespace, localName);
};
AttributeHook.prototype.type = "AttributeHook";
export default AttributeHook;
export { warnWidgetsDecommissioned as default } from "discourse/widgets/widget";
@@ -73,6 +73,7 @@ export default class CodeblockButtons {
this._addClickEvent(element);
}
@bind
cleanup() {
Object.values(this._codeblockButtonClickHandlers || {}).forEach((handler) =>
handler.removeEventListener("click", this._handleClick)
@@ -22,11 +22,6 @@ export default class Flag {
flagModal.args.closeModal();
postAction
.act(flagModal.args.model.flagModel, opts)
.then(() => {
flagModal.appEvents.trigger("post-stream:refresh", {
id: flagModal.args.model.flagModel.id,
});
})
.catch((error) => popupAjaxError(error));
}
}
+29 -28
View File
@@ -1,8 +1,7 @@
import { h } from "virtual-dom";
import attributeHook from "discourse/lib/attribute-hook";
import deprecated from "discourse/lib/deprecated";
import { isDevelopment } from "discourse/lib/environment";
import escape from "discourse/lib/escape";
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
@@ -94,8 +93,17 @@ export function iconHTML(id, params) {
return renderIcon("string", id, params);
}
export function iconNode(id, params) {
return renderIcon("node", id, params);
/**
* @deprecated The widget rendering system has been decommissioned.
* - If you need to create DOM nodes directly, use `iconElement` instead.
* - If you need to render icons in a template, use the `{{icon}}` helper.
*/
export function iconNode() {
warnWidgetsDecommissioned();
}
export function iconElement(id, params) {
return renderIcon("element", id, params);
}
export function convertIconClass(icon) {
@@ -191,35 +199,28 @@ registerIconRenderer({
return html;
},
node(icon, params) {
const id = handleIconId(icon);
element(icon, params) {
const id = escape(handleIconId(icon));
const classes = iconClasses(icon, params) + " svg-node";
const svg = h(
"svg",
{
attributes: { class: classes, "aria-hidden": true },
namespace: SVG_NAMESPACE,
},
[
h("use", {
href: attributeHook("http://www.w3.org/1999/xlink", `#${escape(id)}`),
namespace: SVG_NAMESPACE,
}),
]
);
const svgElement = document.createElementNS(SVG_NAMESPACE, "svg");
svgElement.setAttribute("class", classes);
svgElement.setAttribute("aria-hidden", true);
const useElement = document.createElementNS(SVG_NAMESPACE, "use");
useElement.setAttribute("href", `#${id}`);
svgElement.appendChild(useElement);
if (params.title) {
return h(
"span",
{
title: params.title,
attributes: { class: "svg-icon-title" },
},
[svg]
);
const spanElement = document.createElement("span");
spanElement.setAttribute("class", "svg-icon-title");
spanElement.setAttribute("title", params.title);
spanElement.appendChild(svgElement);
return spanElement;
} else {
return svg;
return svgElement;
}
},
});
+110 -364
View File
@@ -1,5 +1,4 @@
import $ from "jquery";
import { h } from "virtual-dom";
import { addAboutPageActivity } from "discourse/components/about-page";
import { addBulkDropdownButton } from "discourse/components/bulk-select-topics-dropdown";
import { addCardClickListenerSelector } from "discourse/components/card-contents-base";
@@ -11,13 +10,17 @@ import {
} from "discourse/components/composer-editor";
import { addPluginDocumentTitleCounter } from "discourse/components/d-document";
import { addToolbarCallback } from "discourse/components/d-editor";
import {
NON_STREAM_HTML_DECORATOR,
registerHtmlDecorator,
} from "discourse/components/decorated-html";
import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from "discourse/components/glimmer-site-header";
import { addGlobalNotice } from "discourse/components/global-notice";
import { headerButtonsDAG } from "discourse/components/header";
import { headerIconsDAG } from "discourse/components/header/icons";
import { registeredTabs } from "discourse/components/more-topics";
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import { addPluginOutletDecorator } from "discourse/components/plugin-connector";
import { STREAM_HTML_DECORATOR } from "discourse/components/post/cooked-html";
import { addGroupPostSmallActionCode } from "discourse/components/post/small-action";
import {
addPluginReviewableParam,
@@ -56,7 +59,7 @@ import classPrepend, {
} from "discourse/lib/class-prepend";
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import deprecated, { withSilencedDeprecations } from "discourse/lib/deprecated";
import deprecated from "discourse/lib/deprecated";
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
import { downloadCalendar } from "discourse/lib/download-calendar";
import { isTesting } from "discourse/lib/environment";
@@ -67,11 +70,7 @@ import {
registerHighlightJSLanguage,
registerHighlightJSPlugin,
} from "discourse/lib/highlight-syntax";
import {
iconNode,
registerIconRenderer,
replaceIcon,
} from "discourse/lib/icon-library";
import { registerIconRenderer, replaceIcon } from "discourse/lib/icon-library";
import { registerModelTransformer } from "discourse/lib/model-transformers";
import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager";
import { addGTMPageChangedCallback } from "discourse/lib/page-tracker";
@@ -102,7 +101,6 @@ import {
} from "discourse/lib/sidebar/user/categories-section/category-section-link";
import { registerCustomTagSectionLinkPrefixIcon } from "discourse/lib/sidebar/user/tags-section/base-tag-section-link";
import { consolePrefix } from "discourse/lib/source-identifier";
import { includeAttributes } from "discourse/lib/transform-post";
import {
_addTransformerName,
_registerTransformer,
@@ -128,64 +126,9 @@ import { CUSTOM_USER_SEARCH_OPTIONS } from "discourse/select-kit/components/user
import { modifySelectKit } from "discourse/select-kit/lib/plugin-api";
import { addComposerSaveErrorCallback } from "discourse/services/composer";
import { disableDefaultKeyboardShortcuts } from "discourse/services/keyboard-shortcuts";
import { addPostClassesCallback } from "discourse/widgets/post";
import { addDecorator } from "discourse/widgets/post-cooked";
import {
addPostSmallActionClassesCallback,
addPostSmallActionIcon,
} from "discourse/widgets/post-small-action";
import {
addPostTransformCallback,
preventCloak,
} from "discourse/widgets/post-stream";
import { disableNameSuppression } from "discourse/widgets/poster-name";
import {
changeSetting,
createWidget,
decorateWidget,
POST_STREAM_DEPRECATION_OPTIONS,
queryRegistry,
reopenWidget,
warnWidgetsDeprecation,
} from "discourse/widgets/widget";
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
import { addImageWrapperButton } from "discourse-markdown-it/features/image-controls";
const DEPRECATED_POST_STREAM_CLASSES = ["component:scrolling-post-stream"];
const DEPRECATED_POST_STREAM_WIDGETS = [
"actions-summary",
"avatar-flair",
"embedded-post",
"expand-hidden",
"expand-post-button",
"filter-jump-to-post",
"filter-show-all",
"post-article",
"post-article",
"post-avatar-user-info",
"post-avatar",
"post-body",
"post-contents",
"post-date",
"post-edits-indicator",
"post-email-indicator",
"post-gap",
"post-group-request",
"post-links",
"post-locked-indicator",
"post-meta-data",
"post-notice",
"post-placeholder",
"post-stream",
"post",
"poster-name",
"poster-name-title",
"posts-filtered-notice",
"reply-to-tab",
"select-post",
"topic-post-visited-line",
];
const blockedModifications = ["component:topic-list"];
const appliedModificationIds = new WeakMap();
@@ -241,7 +184,7 @@ function wrapWithErrorHandler(func, messageKey) {
class _PluginApi {
constructor(container) {
this.container = container;
this.h = h;
this.h = warnWidgetsDecommissioned;
}
/**
@@ -545,16 +488,17 @@ class _PluginApi {
* },
*
* // for the places in code that render virtual dom elements
* node() {
* return h("svg", {
* attributes: { class: "fa d-icon d-icon-far-face-smile", "aria-hidden": true },
* namespace: "http://www.w3.org/2000/svg"
* },[
* h("use", {
* "href": attributeHook("http://www.w3.org/1999/xlink", `#far-face-smile`),
* namespace: "http://www.w3.org/2000/svg"
* })]
* );
* element() {
* const svgElement = document.createElementNS(SVG_NAMESPACE, "svg");
* svgElement.setAttribute("class", "fa d-icon d-icon-far-face-smile");
* svgElement.setAttribute("aria-hidden", true);
*
* const useElement = document.createElementNS(SVG_NAMESPACE, "use");
* useElement.setAttribute("href", `#far-face-smile`);
*
* svgElement.appendChild(useElement);
*
* return svgElement;
* }
* });
**/
@@ -589,29 +533,59 @@ class _PluginApi {
}
/**
* Used for decorating the `cooked` content of a post after it is rendered
* Used for decorating the `cooked` content of a post after it is rendered.
*
* `callback` will be called when it is time to decorate with an DOM node.
* @param {Function} callback - Function called to decorate a DOM node.
* For stream-only decorators, this callback can return
* a cleanup function that will be executed when decorated content is rerendered/removed.
* The callback receives two parameters:
*
* Use `options.onlyStream` if you only want to decorate posts within a topic,
* and not in other places like the user stream.
* 1. element: HTMLElement to be decorated
* 2. decoratorHelper: Object containing helper methods for the decorator
*
* For example, to add a yellow background to all posts you could do this:
* @param {Object} [opts] - Optional configuration object
* @param {boolean} [opts.onlyStream=false] - If true, only decorate posts within a post stream.
*
* ```
* @example
* // Add a yellow background to the decorated element
* api.decorateCookedElement(
* elem => { elem.style.backgroundColor = 'yellow' }
* elem => {
* elem.style.backgroundColor = 'yellow';
* // Optionally return a cleanup function
* return () => elem.style.backgroundColor = '';
* }
* );
* ```
**/
*
* @example
* // Only decorate posts in the post-stream
* api.decorateCookedElement(
* elem => { elem.classList.add('topic-post'); },
* { onlyStream: true }
* );
*
* @example
* // Using the decorator helper
* api.decorateCookedElement(
* (elem, helper) => {
* const post = helper.model; // Get associated post model
* if (post.firstPost) {
* elem.classList.add('first-post');
* }
* }
* );
*/
decorateCookedElement(callback, opts) {
opts = opts || {};
callback = wrapWithErrorHandler(callback, "broken_decorator_alert");
addDecorator(callback);
registerHtmlDecorator(
callback,
opts.onlyStream ? STREAM_HTML_DECORATOR : NON_STREAM_HTML_DECORATOR
);
this.onAppEvent("decorate-post-cooked-element", callback);
// for backwards compatibility with plugins/themes decorating cooked elements
// using the app events
if (!opts.onlyStream) {
this.onAppEvent("decorate-non-stream-cooked-element", callback);
}
@@ -704,133 +678,20 @@ class _PluginApi {
return makeArray(value).concat(definitions).filter(Boolean);
}
);
// TODO (glimmer-post-stream): remove the fallback when removing the legacy post stream code
withSilencedDeprecations(POST_STREAM_DEPRECATION_OPTIONS.id, () => {
const decoratorFor = (view) => (dec) => {
const currentView = this.container.lookup("service:site").mobileView
? "mobile"
: "desktop";
if (view !== currentView) {
return;
}
const attrs = dec.attrs;
let results = cb(attrs.userCustomFields || {}, attrs);
if (results) {
if (!Array.isArray(results)) {
results = [results];
}
return results.map((result) => {
let iconBody;
if (result.icon) {
iconBody = iconNode(result.icon);
} else if (result.emoji) {
iconBody = result.emoji.split("|").map((name) => {
let widgetAttrs = { name };
if (result.emojiTitle) {
widgetAttrs.title = true;
}
return dec.attach("emoji", widgetAttrs);
});
}
if (result.text) {
iconBody = [iconBody, result.text];
}
if (result.url) {
iconBody = dec.h(
"a",
{ attributes: { href: result.url } },
iconBody
);
}
return dec.h(
"span.poster-icon",
{
className: result.className,
attributes: { title: result.title },
},
iconBody
);
});
}
};
decorateWidget(`poster-name:before`, decoratorFor("mobile"));
decorateWidget(`poster-name:after`, decoratorFor("desktop"));
});
}
/**
* The main interface for extending widgets with additional HTML.
*
* The `name` you pass it should be the name of the widget and a type
* for the decorator. All widgets support `before` and `after` types.
*
* Example:
*
* ```
* api.decorateWidget('post:after', () => {
* return "I am displayed after every post!";
* });
* ```
*
* Your decorator will be called with an instance of a `DecoratorHelper`
* object, which provides methods you can use to build more interesting
* formatting.
*
* ```
* api.decorateWidget('post:after', helper => {
* return helper.h('p.fancy', `I'm an HTML paragraph on post with id ${helper.attrs.id}`);
* });
*
* (View the source for `DecoratorHelper` for more helper methods you
* can use in your plugin decorators.)
*
**/
decorateWidget(name, fn) {
const widgetName = name.split(":")[0];
this.#deprecatedWidgetOverride(widgetName, "decorateWidget");
decorateWidget(name, fn);
* @deprecated the widget rendering system was decommissioned
*/
decorateWidget() {
warnWidgetsDecommissioned();
}
/**
* Adds a new action to a widget that already exists. You can use this to
* add additional functionality from your plugin.
*
* Example:
*
* ```
* api.attachWidgetAction('post', 'annoyMe', () => {
* alert('ANNOYED!');
* });
* ```
**/
attachWidgetAction(widget, actionName, fn) {
const widgetClass =
queryRegistry(widget) ||
this.container.factoryFor(`widget:${widget}`)?.class;
if (!widgetClass) {
// eslint-disable-next-line no-console
console.error(
consolePrefix(),
`attachWidgetAction: Could not find widget ${widget} in registry`
);
return;
}
this.#deprecatedWidgetOverride(widget, "attachWidgetAction");
widgetClass.prototype[actionName] = fn;
* @deprecated the widget rendering system was decommissioned
*/
attachWidgetAction() {
warnWidgetsDecommissioned();
}
/**
@@ -839,10 +700,6 @@ class _PluginApi {
* This function is now an alias to `api.addTrackedPostProperties`.
* Use that function instead.
*
* Add more attributes to the Post's `attrs` object passed through to widgets.
* You'll need to do this if you've added attributes to the serializer for a
* Post and want to use them when you're rendering.
*
* Example:
*
* ```
@@ -852,12 +709,12 @@ class _PluginApi {
*
**/
includePostAttributes(...attributes) {
// TODO (glimmer-post-stream): we can keep this function as an alias to addTrackedPostProperties but it is useful to
// deprecate it for now to get warnings for code that is incompatible with the Glimmer Post Stream because if an
// extension is using it, then it is very likely that there is other code that is incompatible
deprecated(
"`api.includePostAttributes` has been deprecated. Use `api.addTrackedPostProperties` instead.",
POST_STREAM_DEPRECATION_OPTIONS
{
since: "v3.5.0.beta1-dev",
url: "https://meta.discourse.org/t/372063/1",
}
);
this.addTrackedPostProperties(...attributes);
@@ -868,17 +725,13 @@ class _PluginApi {
*
* This method is used to mark properties as tracked for post updates.
*
* It will also add the properties to the list of Post's attributes passed to
* widgets.
*
* You'll need to do this if you've added properties to a Post and want to use
* them when you're rendering.
* You'll need to do this if you've added properties to a Post and need them to be
* automatically updated in the UI when there are changes in the model.
*
* @param {...string} names - The names of the properties to be tracked.
*/
addTrackedPostProperties(...names) {
names.forEach((name) => _addTrackedPostProperty(name));
includeAttributes(...names); // compatibility with widget's attributes
}
/**
@@ -1030,13 +883,10 @@ class _PluginApi {
}
/**
* A hook that is called when the post stream is removed from the DOM.
* This advanced hook should be used if you end up wiring up any
* events that need to be torn down when the user leaves the topic
* page.
**/
cleanupStream(fn) {
addWidgetCleanCallback("post-stream", fn);
* @deprecated the widget rendering system was decommissioned
*/
cleanupStream() {
warnWidgetsDecommissioned();
}
/**
@@ -1095,7 +945,6 @@ class _PluginApi {
* This allows you to override core behavior
**/
disableNameSuppressionOnPosts() {
disableNameSuppression();
this.registerValueTransformer(
"post-meta-data-poster-name-suppress-similar-name",
() => true
@@ -1137,17 +986,10 @@ class _PluginApi {
}
/**
* Changes a setting associated with a widget. For example, if
* you wanted small avatars in the post stream:
*
* ```javascript
* api.changeWidgetSetting('post-avatar', 'size', 'small');
* ```
*
**/
changeWidgetSetting(widgetName, settingName, newValue) {
this.#deprecatedWidgetOverride(widgetName, "changeWidgetSetting");
changeSetting(widgetName, settingName, newValue);
* @deprecated the widget rendering system was decommissioned
*/
changeWidgetSetting() {
warnWidgetsDecommissioned();
}
/**
@@ -1168,29 +1010,21 @@ class _PluginApi {
* ```
**/
preventCloak(postId, prevent = true) {
// TODO (glimmer-post-stream) remove the call to the widget version of preventCloak below
preventCloak(postId); // widgets
preventCloaking(postId, prevent); // glimmer-post-stream
preventCloaking(postId, prevent);
}
/**
* Exposes the widget creating ability to plugins. Plugins can
* register their own widgets and attach them with decorators.
* See `createWidget` in `discourse/widgets/widget` for more info.
**/
createWidget(name, args) {
return createWidget(name, args);
* @deprecated the widget rendering system was decommissioned
*/
createWidget() {
warnWidgetsDecommissioned();
}
/**
* Exposes the widget update ability to plugins. Updates the widget
* registry for the given widget name to include the properties on args
* See `reopenWidget` in `discourse/widgets/widget` from more info.
**/
reopenWidget(name, args) {
this.#deprecatedWidgetOverride(name, "reopenWidget");
return reopenWidget(name, args);
* @deprecated the widget rendering system was decommissioned
*/
reopenWidget() {
warnWidgetsDecommissioned();
}
addFlagProperty() {
@@ -1201,21 +1035,8 @@ class _PluginApi {
}
/**
* Adds a panel to the header
*
* takes a widget name, a value to toggle on, and a function which returns the attrs for the widget
* Example:
* ```javascript
* api.addHeaderPanel('widget-name', 'widgetVisible', function(attrs, state) {
* return { name: attrs.name, description: state.description };
* });
* ```
* 'toggle' is an attribute on the state of the header widget,
*
* 'transformAttrs' is a function which is passed the current attrs and state of the widget,
* and returns a hash of values to pass to attach
*
**/
* @deprecated Use `api.headerIcons` instead.
*/
addHeaderPanel() {
// eslint-disable-next-line no-console
console.error(
@@ -1416,9 +1237,6 @@ class _PluginApi {
"post-small-action-icon",
({ value, context: { code } }) => (key === code ? icon : value)
);
// TODO (glimmer-post-stream): remove the fallback when removing the legacy post stream code
addPostSmallActionIcon(key, icon);
}
/**
@@ -1452,9 +1270,6 @@ class _PluginApi {
...makeArray(callback(post)),
]
);
// TODO (glimmer-post-stream): remove the fallback when removing the legacy post stream code
addPostSmallActionClassesCallback(callback);
}
/**
@@ -1522,9 +1337,6 @@ class _PluginApi {
...makeArray(callback(post)),
]
);
// TODO (glimmer-post-stream): remove the fallback when removing the legacy post stream code
addPostClassesCallback(callback);
}
/**
@@ -1567,26 +1379,10 @@ class _PluginApi {
}
/**
*
* Adds a callback to be executed on the "transformed" post that is passed to the post
* widget.
*
* This allows you to apply transformations on the actual post that is about to be rendered.
*
* Example:
*
* addPostTransformCallback((t)=>{
* // post number 7 is overrated, don't show it ever
* if (t.post_number === 7) { t.cooked = ""; }
* })
* @deprecated the widget rendering system was decommissioned
*/
addPostTransformCallback(callback) {
deprecated(
"`api.addPostTransformCallback` has been deprecated.",
POST_STREAM_DEPRECATION_OPTIONS
);
addPostTransformCallback(callback);
addPostTransformCallback() {
warnWidgetsDecommissioned();
}
/**
@@ -2182,15 +1978,8 @@ class _PluginApi {
}
/**
* Adds a widget to the header-icon ul. The widget must already be created. You can create new widgets
* in a theme or plugin via an initializer prior to calling this function.
*
* ```
* api.addToHeaderIcons(
* createWidget("some-widget")
* ```
*
**/
* @deprecated Use `api.headerIcons` instead
*/
// eslint-disable-next-line no-unused-vars
addToHeaderIcons(icon) {
// eslint-disable-next-line no-console
@@ -2466,7 +2255,7 @@ class _PluginApi {
}
/**
* Add a function to be called when there is a keyDown even on the search-menu widget.
* Add a function to be called when there is a keyDown even on the search-menu.
* This function runs before the default logic, and if one callback returns a falsey value
* the logic chain will stop, to prevent the core behavior from occurring.
*
@@ -2541,31 +2330,10 @@ class _PluginApi {
}
/**
* Calls a method on a mounted widget whenever an app event happens.
*
* For example, if you have a widget with a `key` of `cool-widget` that lives inside the
* `site-header` component, and you wanted it to respond to `thing:happened`, you could do this:
*
* ```
* api.dispatchWidgetAppEvent('site-header', 'cool-widget', 'thing:happened');
* ```
*
* In this case, the `cool-widget` must have a method called `thingHappened`. The event name
* is converted to camelCase and used as the method name for you.
* @deprecated the widget rendering system was decommissioned
*/
dispatchWidgetAppEvent(mountedComponent, widgetKey, appEvent) {
this.modifyClass(
`component:${mountedComponent}`,
{
pluginId: `${mountedComponent}/${widgetKey}/${appEvent}`,
didInsertElement() {
this._super();
this.dispatch(appEvent, widgetKey);
},
},
{ ignoreMissing: true }
);
dispatchWidgetAppEvent() {
warnWidgetsDecommissioned();
}
/**
@@ -3421,39 +3189,17 @@ class _PluginApi {
_addCategoryPropertyForSave(property);
}
// eslint-disable-next-line no-unused-vars
#deprecateModifyClass(className) {
if (DEPRECATED_POST_STREAM_CLASSES.includes(className)) {
deprecated(
`Using api.modifyClass for \`${className}\` has been deprecated and is no longer a supported override.`,
POST_STREAM_DEPRECATION_OPTIONS
);
}
}
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:
// if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) {
// this.container.lookup("service:header").anyWidgetHeaderOverrides = true;
// display notification messages for deprecated classes
// e.g:
//
// if (DEPRECATED_CLASSES.includes(className)) {
// deprecated(
// `The ${widgetName} widget has been deprecated and ${override} is no longer a supported override.`,
// {
// since: "v3.3.0.beta1-dev",
// id: "discourse.header-widget-overrides",
// url: "https://meta.discourse.org/t/316549",
// }
// `Using api.modifyClass for \`${className}\` has been deprecated and is no longer a supported override.`,
// DEPRECATION_OPTIONS
// );
// }
if (DEPRECATED_POST_STREAM_WIDGETS.includes(widgetName)) {
deprecated(
`The \`${widgetName}\` widget has been deprecated and \`api.${override}\` is no longer a supported override.`,
POST_STREAM_DEPRECATION_OPTIONS
);
} else {
warnWidgetsDeprecation(
`Using \`api.${override}\` is deprecated and will soon stop working. Affected widget: ${widgetName}.`
);
}
}
}
@@ -2,10 +2,6 @@ import { tracked } from "@glimmer/tracking";
import { inject as controller } from "@ember/controller";
import { setOwner } from "@ember/owner";
import { service } from "@ember/service";
import {
CLOSE_INITIATED_BY_BUTTON,
CLOSE_INITIATED_BY_ESC,
} from "discourse/components/d-modal";
import { BookmarkFormData } from "discourse/lib/bookmark-form-data";
import Bookmark from "discourse/models/bookmark";
@@ -47,20 +43,8 @@ export default class PostBookmarkManager {
return this.bookmarkApi.update(this.trackedBookmark);
}
afterModalClose(closeData) {
if (!closeData) {
return;
}
if (
closeData.closeWithoutSaving ||
closeData.initiatedBy === CLOSE_INITIATED_BY_ESC ||
closeData.initiatedBy === CLOSE_INITIATED_BY_BUTTON
) {
this.model.appEvents.trigger("post-stream:refresh", {
id: this.model.id,
});
}
afterModalClose() {
// no-op
}
afterSave(bookmarkFormData) {
@@ -1,4 +1,3 @@
import { h } from "virtual-dom";
import { renderIcon } from "discourse/lib/icon-library";
import User from "discourse/models/user";
@@ -54,16 +53,3 @@ export default function renderTopicFeaturedLink(topic) {
return "";
}
}
// deprecated per components/header/topic/featured-link.gjs
export function topicFeaturedLinkNode(topic) {
const meta = extractLinkMeta(topic);
if (meta) {
return h(
"a.topic-featured-link",
{
attributes: { href: meta.href, rel: meta.rel, target: meta.target },
},
[renderIcon("node", "up-right-from-square"), meta.domain]
);
}
}
+11 -254
View File
@@ -1,255 +1,12 @@
import { isEmpty } from "@ember/utils";
import getURL from "discourse/lib/get-url";
import { userPath } from "discourse/lib/url";
import Badge from "discourse/models/badge";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
const _additionalAttributes = new Set();
export function includeAttributes(...attributes) {
attributes.forEach((a) => _additionalAttributes.add(a));
}
export function transformBasicPost(post) {
// Note: it can be dangerous to not use `get` in Ember code, but this is significantly
// faster and has tests to confirm it works. We only call `get` when the property is a CP
const postAtts = {
id: post.id,
hidden: post.hidden,
deleted: post.get("deleted"),
deleted_at: post.deleted_at,
user_deleted: post.user_deleted,
isDeleted: post.deleted,
deletedByAvatarTemplate: null,
deletedByUsername: null,
primary_group_name: post.primary_group_name,
flair_name: post.flair_name,
flair_url: post.flair_url,
flair_bg_color: post.flair_bg_color,
flair_color: post.flair_color,
flair_group_id: post.flair_group_id,
wiki: post.wiki,
lastWikiEdit: post.last_wiki_edit,
firstPost: post.post_number === 1,
post_number: post.post_number,
cooked: post.cooked,
via_email: post.via_email,
isAutoGenerated: post.is_auto_generated,
user_id: post.user_id,
usernameUrl: userPath(post.username),
username: post.username,
badgesGranted: post.badges_granted?.map(
(badge) => Badge.createFromJson(badge)[0]
),
avatar_template: post.avatar_template,
bookmarked: post.bookmarked,
bookmarkReminderAt: post.bookmark_reminder_at,
bookmarkName: post.bookmark_name,
yours: post.yours,
shareUrl: post.get("shareUrl"),
staff: post.staff,
admin: post.admin,
moderator: post.moderator,
groupModerator: post.group_moderator,
new_user: post.trust_level === 0,
name: post.name,
user_title: post.user_title,
title_is_group: post.title_is_group,
created_at: post.created_at,
updated_at: post.updated_at,
canDelete: post.can_delete,
canPermanentlyDelete: false,
showFlagDelete: false,
canRecover: post.can_recover,
canSeeHiddenPost: post.can_see_hidden_post,
canEdit: post.can_edit,
canFlag: !post.get("topic.deleted") && !isEmpty(post.get("flagsAvailable")),
canReviewTopic: false,
reviewableId: post.reviewable_id,
reviewableScoreCount: post.reviewable_score_count,
reviewableScorePendingCount: post.reviewable_score_pending_count,
version: post.version,
canRecoverTopic: false,
canDeleteTopic: false,
canViewEditHistory: post.can_view_edit_history,
canWiki: post.can_wiki,
showLike: false,
liked: false,
canToggleLike: false,
likeCount: false,
actionsSummary: null,
read: post.read,
replyToUsername: null,
replyToName: null,
replyToAvatarTemplate: null,
reply_to_post_number: post.reply_to_post_number,
cooked_hidden: !!post.cooked_hidden,
expandablePost: false,
replyCount: post.reply_count,
locked: post.locked,
userCustomFields: post.user_custom_fields,
readCount: post.readers_count,
canPublishPage: false,
trustLevel: post.trust_level,
userSuspended: post.user_suspended,
locale: post.locale,
is_localized: post.is_localized,
language: post.language,
localization_outdated: post.localization_outdated,
};
_additionalAttributes.forEach((a) => (postAtts[a] = post[a]));
return postAtts;
}
export default function transformPost(
currentUser,
site,
post,
prevPost,
nextPost
) {
// Note: it can be dangerous to not use `get` in Ember code, but this is significantly
// faster and has tests to confirm it works. We only call `get` when the property is a CP
const postType = post.post_type;
const postTypes = site.post_types;
const topic = post.topic;
const details = topic.get("details");
const filteredUpwardsPostID = topic.get("postStream.filterUpwardsPostID");
const filteredRepliesPostNumber = topic.get(
"postStream.filterRepliesToPostNumber"
);
const postAtts = transformBasicPost(post);
const createdBy = details.created_by || {};
postAtts.topic = topic;
postAtts.topicId = topic.id;
postAtts.topicOwner = createdBy.id === post.user_id;
postAtts.topicCreatedById = createdBy.id;
postAtts.post_type = postType;
postAtts.via_email = post.via_email;
postAtts.isAutoGenerated = post.is_auto_generated;
postAtts.isModeratorAction = post.isModeratorAction;
postAtts.isWhisper = post.isWhisper;
postAtts.isSmallAction =
postType === postTypes.small_action || post.action_code === "split_topic";
postAtts.canBookmark = !!currentUser;
postAtts.canManage = post.canManage;
postAtts.canViewRawEmail = currentUser && currentUser.can_view_raw_email;
postAtts.canArchiveTopic = !!details.can_archive_topic;
postAtts.canCloseTopic = !!details.can_close_topic;
postAtts.canSplitMergeTopic = !!details.can_split_merge_topic;
postAtts.canEditStaffNotes = post.canEditStaffNotes;
postAtts.canReplyAsNewTopic = !!details.can_reply_as_new_topic;
postAtts.canReviewTopic = !!details.can_review_topic;
postAtts.canPublishPage = post.canPublishPage;
postAtts.isWarning = topic.is_warning;
postAtts.links = post.get("internalLinks");
// on the glimmer post menu this logic was implemented on the PostMenu component as it depends on
// two post object instances
// if you change the logic replyDirectlyBelow here, while the widget post menu is still around, please
// be sure to also update the logic on the Glimmer PostMenu component
postAtts.replyDirectlyBelow =
nextPost &&
nextPost.reply_to_post_number === post.post_number &&
post.post_number !== filteredRepliesPostNumber;
postAtts.replyDirectlyAbove =
prevPost &&
post.id !== filteredUpwardsPostID &&
post.reply_to_post_number === prevPost.post_number;
postAtts.linkCounts = post.link_counts;
postAtts.actionCode = post.action_code;
postAtts.actionCodeWho = post.action_code_who;
postAtts.actionCodePath = getURL(post.action_code_path || `/t/${topic.id}`);
postAtts.topicUrl = topic.get("url");
postAtts.isSaving = post.isSaving;
postAtts.staged = post.staged;
postAtts.user = post.user;
if (post.notice) {
postAtts.notice = post.notice;
postAtts.noticeCreatedByUser = post.notice_created_by_user;
if (postAtts.notice.type === "returning_user") {
postAtts.notice.lastPostedAt = new Date(post.notice.last_posted_at);
}
}
if (post.post_number === 1 && topic.requested_group_name) {
postAtts.requestedGroupName = topic.requested_group_name;
}
if (postAtts.isDeleted) {
postAtts.deletedByAvatarTemplate = post.get("deletedBy.avatar_template");
postAtts.deletedByUsername = post.get("deletedBy.username");
}
const replyToUser = post.get("reply_to_user");
if (replyToUser) {
postAtts.replyToUsername = replyToUser.username;
postAtts.replyToName = replyToUser.name;
postAtts.replyToAvatarTemplate = replyToUser.avatar_template;
}
if (post.actions_summary) {
postAtts.actionsSummary = post.actions_summary
.filter((a) => {
return a.actionType.name_key !== "like" && a.acted;
})
.map((a) => {
return {
id: a.id,
postId: post.id,
action: a.actionType.name_key,
canUndo: a.can_undo,
description: a.actionType.translatedDescription,
};
});
}
postAtts.liked = post.liked;
const likeAction = post.likeAction;
if (likeAction) {
postAtts.canToggleLike = likeAction.get("canToggle");
postAtts.showLike = postAtts.liked || postAtts.canToggleLike;
postAtts.likeCount = likeAction.count;
} else if (
!currentUser ||
(topic.archived && topic.user_id !== currentUser.id)
) {
postAtts.showLike = true;
}
postAtts.canDelete = post.canDelete;
postAtts.canDeleteTopic = post.canDeleteTopic;
postAtts.canPermanentlyDelete = post.canPermanentlyDelete;
postAtts.canRecover = post.canRecover;
postAtts.canRecoverTopic = post.canRecoverTopic;
if (postAtts.post_number === 1) {
postAtts.expandablePost = topic.expandable_first_post;
// Show a "Flag to delete" message if not staff and you can't
// otherwise delete it.
postAtts.showFlagDelete =
!postAtts.canDelete &&
postAtts.yours &&
postAtts.canFlag &&
currentUser &&
!currentUser.staff;
} else {
postAtts.canDelete =
postAtts.canDelete &&
!post.deleted_at &&
currentUser &&
(currentUser.staff || !post.user_deleted);
}
_additionalAttributes.forEach((a) => (postAtts[a] = post[a]));
return postAtts;
}
export {
warnWidgetsDecommissioned as includeAttributes,
warnWidgetsDecommissioned as transformBasicPost,
warnWidgetsDecommissioned as transformPost,
} from "discourse/widgets/widget";
+1 -1
View File
@@ -124,5 +124,5 @@ loaderShim("truth-helpers/helpers/not", () =>
loaderShim("truth-helpers/helpers/or", () =>
importSync("truth-helpers/helpers/or")
);
loaderShim("virtual-dom", () => importSync("virtual-dom"));
loaderShim("virtual-dom", () => importSync("discourse/widgets/virtual-dom"));
loaderShim("xss", () => importSync("xss"));
@@ -3,7 +3,6 @@ import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import RestModel from "discourse/models/rest";
// TODO (glimmer-post-stream) is this model still used?
export default class ActionSummary extends RestModel {
@or("can_undo", "can_act") canToggle;
@@ -1137,8 +1137,6 @@ export default class Composer extends RestModel {
const cooked = this.getCookedHtml();
post.setProperties({ cooked, staged: true });
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: post.id });
return promise
.then(() => {
@@ -1150,8 +1148,6 @@ export default class Composer extends RestModel {
.catch(rollback)
.finally(() => {
post.set("staged", false);
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: post.id });
});
}
@@ -289,9 +289,6 @@ export default class PostStream extends RestModel {
? element.getBoundingClientRect().top
: null;
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
DiscourseURL.jumpToPost(postNumber, {
originalTopOffset,
});
@@ -310,9 +307,6 @@ export default class PostStream extends RestModel {
post_id: postID,
});
return this.refresh({ refreshInPlace: true }).then(() => {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
if (this.posts && this.posts.length > 1) {
const postNumber = this.posts[1].post_number;
DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true });
@@ -448,9 +442,6 @@ export default class PostStream extends RestModel {
}
gapExpanded() {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
// resets the reply count in posts-filtered-notice
// because once a gap has been expanded that count is no longer exact
if (this.streamFilters && this.streamFilters.replies_to_post_number) {
-4
View File
@@ -226,8 +226,6 @@ export default class Post extends RestModel {
@or("deleted_at", "user_deleted") recoverable; // post or content still can be recovered
@propertyEqual("topic.details.created_by.id", "user_id") topicOwner;
@alias("topic.details.created_by.id") topicCreatedById;
@alias("deletedBy") postDeletedBy; // TODO (glimmer-post-stream): check if this alias can be removed after removing the widget code
@alias("deletedAt") postDeletedAt; // TODO (glimmer-post-stream): check if this alias can be removed after removing the widget code
constructor() {
super(...arguments);
@@ -691,8 +689,6 @@ export default class Post extends RestModel {
target: "post",
targetId: this.id,
});
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: this.id });
}
deleteBookmark(bookmarked) {
-68
View File
@@ -18,7 +18,6 @@ import Category from "discourse/models/category";
import PostActionType from "discourse/models/post-action-type";
import RestModel from "discourse/models/rest";
import TrustLevel from "discourse/models/trust-level";
import { havePostStreamWidgetExtensions } from "discourse/widgets/post-stream";
@singleton
export default class Site extends RestModel {
@@ -83,7 +82,6 @@ export default class Site extends RestModel {
}
@service siteSettings;
@service currentUser;
@service capabilities;
@trackedArray categories;
@@ -92,7 +90,6 @@ export default class Site extends RestModel {
@sort("categories", "topicCountDesc") categoriesByCount;
#glimmerPostStreamEnabled;
#siteInitialized = false;
init() {
@@ -163,71 +160,6 @@ export default class Site extends RestModel {
return this.mobileView;
}
get useGlimmerPostStream() {
if (this.#glimmerPostStreamEnabled !== undefined) {
// Use cached value after the first call to prevent duplicate messages in the console
return this.#glimmerPostStreamEnabled;
}
let enabled;
/* eslint-disable no-console */
let settingValue = this.siteSettings.deactivate_widgets_rendering
? "enabled" // if widgets rendering is deactivated, we always use the glimmer post stream
: this.siteSettings.glimmer_post_stream_mode;
if (
settingValue === "disabled" &&
this.currentUser?.use_glimmer_post_stream_mode_auto_mode
) {
settingValue = "auto";
}
if (settingValue === "disabled") {
enabled = false;
} else {
if (settingValue === "enabled") {
if (havePostStreamWidgetExtensions) {
console.log(
[
"⚠️ Using the new 'glimmer' post stream, even though some themes/plugins are not ready.\n" +
"The following plugins and/or themes are using deprecated APIs and may have broken customizations: \n",
...Array.from(havePostStreamWidgetExtensions).sort(),
].join("\n- ")
);
} else {
if (!isTesting() && !isRailsTesting()) {
console.log("✅ Using the new 'glimmer' post stream!");
}
}
enabled = true;
} else {
// auto
if (havePostStreamWidgetExtensions) {
console.warn(
[
"⚠️ Detected themes/plugins which are incompatible with the new 'glimmer' post stream. Falling back to the old implementation.\n" +
"The following plugins and/or themes are using deprecated APIs: \n",
...Array.from(havePostStreamWidgetExtensions).sort(),
].join("\n- ")
);
enabled = false;
} else {
if (!isTesting() && !isRailsTesting()) {
console.log("✅ Using the new 'glimmer' post stream!");
}
enabled = true;
}
}
}
/* eslint-enable no-console */
this.#glimmerPostStreamEnabled = enabled;
return enabled;
}
@computed("categories.[]")
get categoriesById() {
const map = new Map();
@@ -1193,24 +1193,15 @@ export default class ComposerService extends Service {
return this.destroyDraft().then(() => {
this.close();
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
return result;
});
}
if (this.get("model.editingPost")) {
this.appEvents.trigger("composer:edited-post");
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", {
id: parseInt(result.responseJson.id, 10),
});
if (result.responseJson.post.post_number === 1) {
this.appEvents.trigger("header:update-topic", composer.topic);
}
} else {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh");
}
if (result.responseJson.action === "create_post") {
@@ -1278,9 +1269,6 @@ export default class ComposerService extends Service {
staged = composer.get("stagedPost");
}
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:posted", staged);
this.messageBus.pause();
promise.finally(() => this.messageBus.resume());
@@ -35,6 +35,7 @@ export const CRITICAL_DEPRECATIONS = [
"discourse.script-tag-discourse-plugin",
"discourse.post-stream-widget-overrides",
"discourse.widgets-end-of-life",
"discourse.widgets-decommissioned",
];
const REPLACEMENT_URLS = {};
@@ -628,11 +628,7 @@ export default class KeyboardShortcutLib extends Service {
actionMethod = topicRoute.actions[action];
}
const result = actionMethod.call(topicController, post);
if (result && result.then) {
// TODO (glimmer-post-stream) the Glimmer Post Stream does not listen to this event
this.appEvents.trigger("post-stream:refresh", { id: selectedPostId });
}
actionMethod.call(topicController, post);
}
}
+65 -145
View File
@@ -18,7 +18,6 @@ import PostTextSelection from "discourse/components/post-text-selection";
import PrivateMessageGlyph from "discourse/components/private-message-glyph";
import ReviewableCreatedBy from "discourse/components/reviewable-created-by";
import ReviewableCreatedByName from "discourse/components/reviewable-created-by-name";
import ScrollingPostStream from "discourse/components/scrolling-post-stream";
import SelectedPosts from "discourse/components/selected-posts";
import SharedDraftControls from "discourse/components/shared-draft-controls";
import SignupCta from "discourse/components/signup-cta";
@@ -425,153 +424,74 @@ export default <template>
id="topic"
data-topic-id={{@controller.model.id}}
>
{{#if @controller.site.useGlimmerPostStream}}
<div class="posts-wrapper">
<span>
<PluginOutlet
@name="topic-above-posts"
@connectorTagName="div"
@outletArgs={{lazyHash model=@controller.model}}
/>
</span>
{{#unless @controller.model.postStream.loadingFilter}}
<PostStream
@postStream={{@controller.model.postStream}}
@posts={{@controller.postsToRender}}
@canCreatePost={{@controller.model.details.can_create_post}}
@multiSelect={{@controller.multiSelect}}
@selectedPostsCount={{@controller.selectedPostsCount}}
@filteredPostsCount={{@controller.model.postStream.filteredPostsCount}}
@selectedQuery={{@controller.selectedQuery}}
@gaps={{@controller.model.postStream.gaps}}
@showReadIndicator={{@controller.model.show_read_indicator}}
@streamFilters={{@controller.model.postStream.streamFilters}}
@lastReadPostNumber={{@controller.userLastReadPostNumber}}
@highestPostNumber={{@controller.highestPostNumber}}
@showFlags={{@controller.showPostFlags}}
@editPost={{@controller.editPost}}
@showHistory={{routeAction "showHistory"}}
@showLogin={{routeAction "showLogin"}}
@showRawEmail={{routeAction "showRawEmail"}}
@deletePost={{@controller.deletePost}}
@permanentlyDeletePost={{@controller.permanentlyDeletePost}}
@recoverPost={{@controller.recoverPost}}
@expandHidden={{@controller.expandHidden}}
@toggleBookmark={{@controller.toggleBookmark}}
@togglePostType={{@controller.togglePostType}}
@rebakePost={{@controller.rebakePost}}
@changePostOwner={{@controller.changePostOwner}}
@grantBadge={{@controller.grantBadge}}
@changeNotice={{@controller.changeNotice}}
@lockPost={{@controller.lockPost}}
@unlockPost={{@controller.unlockPost}}
@unhidePost={{@controller.unhidePost}}
@replyToPost={{@controller.replyToPost}}
@toggleWiki={{@controller.toggleWiki}}
@showTopReplies={{@controller.showTopReplies}}
@cancelFilter={{@controller.cancelFilter}}
@removeAllowedUser={{@controller.removeAllowedUser}}
@removeAllowedGroup={{@controller.removeAllowedGroup}}
@topVisibleChanged={{@controller.topVisibleChanged}}
@currentPostChanged={{@controller.currentPostChanged}}
@currentPostScrolled={{@controller.currentPostScrolled}}
@bottomVisibleChanged={{@controller.bottomVisibleChanged}}
@togglePostSelection={{@controller.togglePostSelection}}
@selectReplies={{@controller.selectReplies}}
@selectBelow={{@controller.selectBelow}}
@fillGapBefore={{@controller.fillGapBefore}}
@fillGapAfter={{@controller.fillGapAfter}}
@showInvite={{routeAction "showInvite"}}
@showPagePublish={{routeAction "showPagePublish"}}
@filteringRepliesToPostNumber={{@controller.replies_to_post_number}}
@updateTopicPageQueryParams={{@controller.updateTopicPageQueryParams}}
@postSelected={{@controller.postSelected}}
@topicPageQueryParams={{getProperties
@controller
@controller.queryParams
}}
@topic={{@controller.model}}
/>
{{/unless}}
</div>
{{else}}
<div class="posts-wrapper">
<ConditionalLoadingSpinner
@condition={{@controller.model.postStream.loadingAbove}}
<div class="posts-wrapper">
<span>
<PluginOutlet
@name="topic-above-posts"
@connectorTagName="div"
@outletArgs={{lazyHash model=@controller.model}}
/>
</span>
<span>
<PluginOutlet
@name="topic-above-posts"
@connectorTagName="div"
@outletArgs={{lazyHash model=@controller.model}}
/>
</span>
{{#unless @controller.model.postStream.loadingFilter}}
<ScrollingPostStream
@posts={{@controller.postsToRender}}
@canCreatePost={{@controller.model.details.can_create_post}}
@multiSelect={{@controller.multiSelect}}
@selectedPostsCount={{@controller.selectedPostsCount}}
@filteredPostsCount={{@controller.model.postStream.filteredPostsCount}}
@selectedQuery={{@controller.selectedQuery}}
@gaps={{@controller.model.postStream.gaps}}
@showReadIndicator={{@controller.model.show_read_indicator}}
@streamFilters={{@controller.model.postStream.streamFilters}}
@lastReadPostNumber={{@controller.userLastReadPostNumber}}
@highestPostNumber={{@controller.highestPostNumber}}
@showFlags={{@controller.showPostFlags}}
@editPost={{@controller.editPost}}
@showHistory={{routeAction "showHistory"}}
@showLogin={{routeAction "showLogin"}}
@showRawEmail={{routeAction "showRawEmail"}}
@deletePost={{@controller.deletePost}}
@permanentlyDeletePost={{@controller.permanentlyDeletePost}}
@recoverPost={{@controller.recoverPost}}
@expandHidden={{@controller.expandHidden}}
@toggleBookmark={{@controller.toggleBookmark}}
@togglePostType={{@controller.togglePostType}}
@rebakePost={{@controller.rebakePost}}
@changePostOwner={{@controller.changePostOwner}}
@grantBadge={{@controller.grantBadge}}
@changeNotice={{@controller.changeNotice}}
@lockPost={{@controller.lockPost}}
@unlockPost={{@controller.unlockPost}}
@unhidePost={{@controller.unhidePost}}
@replyToPost={{@controller.replyToPost}}
@toggleWiki={{@controller.toggleWiki}}
@showTopReplies={{@controller.showTopReplies}}
@cancelFilter={{@controller.cancelFilter}}
@removeAllowedUser={{@controller.removeAllowedUser}}
@removeAllowedGroup={{@controller.removeAllowedGroup}}
@topVisibleChanged={{@controller.topVisibleChanged}}
@currentPostChanged={{@controller.currentPostChanged}}
@currentPostScrolled={{@controller.currentPostScrolled}}
@bottomVisibleChanged={{@controller.bottomVisibleChanged}}
@togglePostSelection={{@controller.togglePostSelection}}
@selectReplies={{@controller.selectReplies}}
@selectBelow={{@controller.selectBelow}}
@fillGapBefore={{@controller.fillGapBefore}}
@fillGapAfter={{@controller.fillGapAfter}}
@showInvite={{routeAction "showInvite"}}
@showPagePublish={{routeAction "showPagePublish"}}
@filteringRepliesToPostNumber={{@controller.replies_to_post_number}}
@updateTopicPageQueryParams={{@controller.updateTopicPageQueryParams}}
@topicPageQueryParams={{getProperties
@controller
@controller.queryParams
}}
/>
{{/unless}}
<ConditionalLoadingSpinner
@condition={{@controller.model.postStream.loadingBelow}}
{{#unless @controller.model.postStream.loadingFilter}}
<PostStream
@postStream={{@controller.model.postStream}}
@canCreatePost={{@controller.model.details.can_create_post}}
@multiSelect={{@controller.multiSelect}}
@selectedPostsCount={{@controller.selectedPostsCount}}
@filteredPostsCount={{@controller.model.postStream.filteredPostsCount}}
@selectedQuery={{@controller.selectedQuery}}
@gaps={{@controller.model.postStream.gaps}}
@showReadIndicator={{@controller.model.show_read_indicator}}
@streamFilters={{@controller.model.postStream.streamFilters}}
@lastReadPostNumber={{@controller.userLastReadPostNumber}}
@highestPostNumber={{@controller.highestPostNumber}}
@showFlags={{@controller.showPostFlags}}
@editPost={{@controller.editPost}}
@showHistory={{routeAction "showHistory"}}
@showLogin={{routeAction "showLogin"}}
@showRawEmail={{routeAction "showRawEmail"}}
@deletePost={{@controller.deletePost}}
@permanentlyDeletePost={{@controller.permanentlyDeletePost}}
@recoverPost={{@controller.recoverPost}}
@expandHidden={{@controller.expandHidden}}
@toggleBookmark={{@controller.toggleBookmark}}
@togglePostType={{@controller.togglePostType}}
@rebakePost={{@controller.rebakePost}}
@changePostOwner={{@controller.changePostOwner}}
@grantBadge={{@controller.grantBadge}}
@changeNotice={{@controller.changeNotice}}
@lockPost={{@controller.lockPost}}
@unlockPost={{@controller.unlockPost}}
@unhidePost={{@controller.unhidePost}}
@replyToPost={{@controller.replyToPost}}
@toggleWiki={{@controller.toggleWiki}}
@showTopReplies={{@controller.showTopReplies}}
@cancelFilter={{@controller.cancelFilter}}
@removeAllowedUser={{@controller.removeAllowedUser}}
@removeAllowedGroup={{@controller.removeAllowedGroup}}
@topVisibleChanged={{@controller.topVisibleChanged}}
@currentPostChanged={{@controller.currentPostChanged}}
@currentPostScrolled={{@controller.currentPostScrolled}}
@bottomVisibleChanged={{@controller.bottomVisibleChanged}}
@togglePostSelection={{@controller.togglePostSelection}}
@selectReplies={{@controller.selectReplies}}
@selectBelow={{@controller.selectBelow}}
@fillGapBefore={{@controller.fillGapBefore}}
@fillGapAfter={{@controller.fillGapAfter}}
@showInvite={{routeAction "showInvite"}}
@showPagePublish={{routeAction "showPagePublish"}}
@filteringRepliesToPostNumber={{@controller.replies_to_post_number}}
@updateTopicPageQueryParams={{@controller.updateTopicPageQueryParams}}
@postSelected={{@controller.postSelected}}
@topicPageQueryParams={{getProperties
@controller
@controller.queryParams
}}
@topic={{@controller.model}}
/>
</div>
{{/if}}
{{/unless}}
</div>
<div id="topic-bottom"></div>
<ConditionalLoadingSpinner
@@ -1,23 +0,0 @@
import { hbs } from "ember-cli-htmlbars";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
// This shim is nesting everything into a DIV and changing the HTML but only thw two voting plugins
// are using this widget outside of core.
registerWidgetShim(
"small-user-list",
"div",
hbs`
<SmallUserList class={{@data.listClassName}}
@ariaLabel={{@data.ariaLabel}}
@users={{@data.users}}
@addSelf={{@data.addSelf}}
@count={{@data.count}}
@description={{@data.description}}
@isVisible={{@data.isVisible}}/>`
);
registerWidgetShim(
"actions-summary",
"section.post-actions",
hbs`<Post::ActionsSummary @post={{@data.post}} />`
);
+7 -51
View File
@@ -1,52 +1,8 @@
import { convertIconClass, iconNode } from "discourse/lib/icon-library";
import { escapeExpression } from "discourse/lib/utilities";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
createWidget("avatar-flair", {
tagName: "div.avatar-flair",
isIcon(attrs) {
return attrs.flair_url && !attrs.flair_url.includes("/");
},
title(attrs) {
return attrs.flair_name;
},
buildClasses(attrs) {
let defaultClass = `avatar-flair-${attrs.flair_name} ${
attrs.flair_bg_color ? "rounded" : ""
}`;
if (!this.isIcon(attrs)) {
defaultClass += " avatar-flair-image";
}
return defaultClass;
},
buildAttributes(attrs) {
let style = "";
if (!this.isIcon(attrs)) {
style +=
"background-image: url(" + escapeExpression(attrs.flair_url) + "); ";
}
if (attrs.flair_bg_color) {
style +=
"background-color: #" + escapeExpression(attrs.flair_bg_color) + "; ";
}
if (attrs.flair_color) {
style += "color: #" + escapeExpression(attrs.flair_color) + "; ";
}
return { style };
},
html(attrs) {
if (this.isIcon(attrs)) {
const icon = convertIconClass(attrs.flair_url);
return [iconNode(icon)];
} else {
return [];
}
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,8 +0,0 @@
import { hbs } from "ember-cli-htmlbars";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
registerWidgetShim(
"bookmark-menu-shim",
"div.bookmark-menu-shim",
hbs`<BookmarkMenu @bookmarkManager={{@data.bookmarkManager}} @buttonClasses="btn-flat" />`
);
+32 -157
View File
@@ -1,165 +1,40 @@
import $ from "jquery";
import { h } from "virtual-dom";
import { iconNode } from "discourse/lib/icon-library";
import DiscourseURL from "discourse/lib/url";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export const ButtonClass = {
tagName: "button.widget-button.btn",
/**
* @deprecated the widget rendering system was decommissioned
*/
buildClasses() {},
buildClasses(attrs) {
let className = this.attrs.className || "";
/**
* @deprecated the widget rendering system was decommissioned
*/
buildAttributes() {},
let hasText = attrs.translatedLabel || attrs.label || attrs.contents;
/**
* @deprecated the widget rendering system was decommissioned
*/
_buildIcon() {},
if (!hasText) {
className += " no-text";
}
/**
* @deprecated the widget rendering system was decommissioned
*/
html() {},
if (attrs.icon) {
className += " btn-icon";
if (hasText) {
className += "-text";
}
} else if (hasText) {
className += " btn-text";
}
return className;
},
buildAttributes() {
const attrs = this.attrs;
const attributes = {};
let title = attrs.translatedTitle;
if (!title && attrs.title) {
title = i18n(attrs.title, attrs.titleOptions);
}
if (title) {
attributes.title = title;
}
if (attrs.role) {
attributes["role"] = attrs.role;
}
if (attrs.translatedAriaLabel) {
attributes["aria-label"] = attrs.translatedAriaLabel;
}
if (attrs.ariaExpanded) {
attributes["aria-expanded"] = attrs.ariaExpanded;
}
if (attrs.ariaControls) {
attributes["aria-controls"] = attrs.ariaControls;
}
if (attrs.ariaPressed) {
attributes["aria-pressed"] = attrs.ariaPressed;
}
if (attrs.ariaLive) {
attributes["aria-live"] = attrs.ariaLive;
}
if (attrs.tabAttrs) {
const tab = attrs.tabAttrs;
attributes["aria-selected"] = tab["aria-selected"];
attributes["tabindex"] = tab["tabindex"];
attributes["aria-controls"] = tab["aria-controls"];
attributes["id"] = attrs.id;
}
if (attrs.disabled) {
attributes.disabled = "true";
}
if (attrs.data) {
Object.keys(attrs.data).forEach(
(k) => (attributes[`data-${k}`] = attrs.data[k])
);
}
return attributes;
},
_buildIcon(attrs) {
const icon = iconNode(attrs.icon, { class: attrs.iconClass });
if (attrs["aria-label"]) {
icon.properties.attributes["role"] = "img";
icon.properties.attributes["aria-hidden"] = false;
}
return icon;
},
html(attrs) {
const contents = [];
const left = !attrs.iconRight;
if (attrs.icon && left) {
contents.push(this._buildIcon(attrs));
}
if (attrs.emoji && left) {
contents.push(this.attach("emoji", { name: attrs.emoji }));
}
if (attrs.label) {
contents.push(
h("span.d-button-label", i18n(attrs.label, attrs.labelOptions))
);
}
if (attrs.translatedLabel) {
contents.push(
h(
"span.d-button-label",
attrs.translatedLabel.toString(),
attrs.translatedLabelOptions
)
);
}
if (attrs.contents) {
contents.push(attrs.contents);
}
if (attrs.emoji && !left) {
contents.push(this.attach("emoji", { name: attrs.emoji }));
}
if (attrs.icon && !left) {
contents.push(this._buildIcon(attrs));
}
return contents;
},
click(e) {
const attrs = this.attrs;
if (attrs.disabled) {
return;
}
$(`button.widget-button`).removeClass("d-hover").blur();
if (attrs.secondaryAction) {
this.sendWidgetAction(attrs.secondaryAction);
}
if (attrs.url) {
return DiscourseURL.routeTo(attrs.url);
}
if (attrs.sendActionEvent) {
return this.sendWidgetAction(attrs.action, e);
}
return this.sendWidgetAction(attrs.action, attrs.actionParam);
},
/**
* @deprecated the widget rendering system was decommissioned
*/
click() {},
};
export default createWidget("button", ButtonClass);
createWidget(
"flat-button",
Object.assign(ButtonClass, {
tagName: "button.widget-button.btn-flat",
})
);
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,10 +1,8 @@
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import RawHtml from "discourse/widgets/raw-html";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
// Right now it's RawHTML. Eventually it should emit nodes
export default class CategoryLink extends RawHtml {
constructor(attrs) {
attrs.html = `<span>${categoryBadgeHTML(attrs.category, attrs)}</span>`;
super(attrs);
}
}
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,75 +1,39 @@
import { getOwner } from "@ember/owner";
import { scheduleOnce } from "@ember/runloop";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export default class ComponentConnector {
constructor(
widget,
componentName,
opts,
trackedProperties,
{ applyStyle = true } = {}
) {
this.widget = widget;
this.opts = opts;
this.componentName = componentName;
this.trackedProperties = trackedProperties || [];
this.applyStyle = applyStyle;
this._component = null;
constructor() {
warnWidgetsDecommissioned();
}
init() {
const elem = document.createElement("div");
if (this.applyStyle) {
elem.style.display = "inline-flex";
}
elem.className = "widget-component-connector";
this.elem = elem;
scheduleOnce("afterRender", this, this.connectComponent);
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {}
return this.elem;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
destroy() {}
destroy() {
this._component?.destroy();
}
/**
* @deprecated the widget rendering system was decommissioned
*/
update() {}
update(prev) {
// mutated external properties might not correctly update the underlying component
// in this case we can define trackedProperties, if different from previous
// state we will re-init the whole component, be careful when using this
// to not track a property which would be updated too often (on scroll for example)
let shouldInit = false;
this.trackedProperties.forEach((prop) => {
if (prev.opts[prop] !== this.opts[prop]) {
shouldInit = true;
}
});
if (shouldInit) {
return this.init();
}
return null;
}
connectComponent() {
const { elem, opts, widget, componentName } = this;
const mounted = widget._findView();
const component = getOwner(mounted)
.factoryFor(`component:${componentName}`)
.create(opts);
// component connector is not triggering didReceiveAttrs
// we force it for selectKit components
if (component.selectKit) {
component.didReceiveAttrs();
}
mounted._connected.push(component);
component.renderer.appendTo(component, elem);
this._component = component;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
connectComponent() {}
}
ComponentConnector.prototype.type = "Widget";
+22 -30
View File
@@ -1,37 +1,29 @@
import { getOwner } from "@ember/owner";
import { next } from "@ember/runloop";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export default class Connector {
constructor(widget, opts) {
this.widget = widget;
this.opts = opts;
constructor() {
warnWidgetsDecommissioned();
}
init() {
const elem = document.createElement("div");
elem.classList.add("widget-connector");
const { opts, widget } = this;
next(() => {
const mounted = widget._findView();
if (opts.component) {
const component = getOwner(mounted)
.factoryFor("component:connector-container")
.create({
layoutName: `components/${opts.component}`,
model: widget.findAncestorModel(),
});
mounted._connected.push(component);
component.renderer.appendTo(component, elem);
}
});
return elem;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
update() {}
}
Connector.prototype.type = "Widget";
@@ -1,179 +1,54 @@
import { hasInternalComponentManager } from "@glimmer/manager";
import { h } from "virtual-dom";
import deprecated from "discourse/lib/deprecated";
import Connector from "discourse/widgets/connector";
import PostCooked from "discourse/widgets/post-cooked";
import RawHtml from "discourse/widgets/raw-html";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { POST_STREAM_DEPRECATION_OPTIONS } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
class DecoratorHelper {
constructor(widget, attrs, state) {
this.widget = widget;
this.attrs = attrs;
this.canConnectComponent = true;
this.state = state;
this.register = widget.register;
this.register.deprecateContainer(this);
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export default class DecoratorHelper {
constructor() {
warnWidgetsDecommissioned();
}
/**
* The `h` helper allows you to build up a virtual dom easily.
*
* Example:
*
* ```
* // renders `<div class='some-class'><p>paragraph</p></div>`
* return helper.h('div.some-class', helper.h('p', 'paragraph'));
* ```
* Check out https://github.com/Matt-Esch/virtual-dom/blob/master/virtual-hyperscript/README.md
* for more details on how to construct markup with h.
**/
// h() is attached via `prototype` below
/**
* Attach another widget inside this one.
*
* ```
* return helper.attach('widget-name');
* ```
* @deprecated the widget rendering system was decommissioned
*/
attach(name, attrs, state) {
attrs = attrs || this.widget.attrs;
state = state || this.widget.state;
return this.widget.attach(name, attrs, state);
}
get model() {
return this.widget.findAncestorModel();
}
attach() {}
/**
* Returns the model associated with this widget. When decorating
* posts this will normally be the post.
*
* Example:
*
* ```
* const post = helper.getModel();
* console.log(post.get('id'));
* ```
**/
getModel() {
return this.model;
}
/**
* If your decorator must produce raw HTML, you can use this helper
* to display it. It is preferred to use the `h` helper and create
* the HTML yourself whenever possible.
*
* Example:
*
* ```
* return helper.rawHtml(`<p>I will be displayed</p`);
* ```
**/
rawHtml(html) {
return new RawHtml({ html });
}
/**
* Renders `cooked` content using all the helpers and decorators that
* are attached to that. This is useful if you want to render a post's
* content or a different version of it.
*
* Example:
*
* ```
* return helper.cooked(`<p>Cook me</p>`);
* ```
**/
cooked(cooked) {
return new PostCooked({ cooked }, this);
}
/**
* You can use this bridge to mount an Ember Component inside the virtual
* DOM post stream. Note that this is a bit bizarre, as our core app
* is rendered in Ember, then we switch to a virtual dom, and this
* allows part of that virtual dom to use Ember again!
*
* It really only exists as backwards compatibility for some old
* plugins that would be difficult to update otherwise. There are
* performance reasons not to use this, so be careful and avoid
* using it whenever possible.
*
* Example:
*
* ```
* helper.connect({ component: 'my-component-name' });
* ```
**/
connect(details) {
return new Connector(this.widget, details);
}
/**
* Returns an element containing a rendered glimmer template. For full usage instructions,
* see `widgets/render-glimmer.js`.
*
* DEPRECATION NOTICES:
* - using a string describing a new wrapper element as the `targetElement` parameter is deprecated.
* Use an existing HTML element instead.
* - using a template compiled via `ember-cli-htmlbars` as the `component` parameter is deprecated.
* You should provide a component instead.
*
* Example usage in a `.gjs` file:
*
* ```
* api.decorateCookedElement((cooked, helper) => {
* // Or append to an existing element
* helper.renderGlimmer(
* cooked.querySelector(".some-container"),
* <template>I will be appended to some-container</template>
* );
* }, { onlyStream: true });
* ```
*
* @deprecated the widget rendering system was decommissioned
*/
renderGlimmer(targetElement, component, data) {
// Ideally we should throw an error here, but we can't for now to prevent existing incompatible customizations from
// crashing the app. Instead, we will log a deprecation warning while we're in the process of migrating to the new
// Glimmer Post Stream API.
if (!(targetElement instanceof Element)) {
deprecated(
"The `targetElement` parameter provided to `helper.renderGlimmer` is invalid. It must be an existing HTML element. Using a string to describe a new wrapper element is deprecated.",
POST_STREAM_DEPRECATION_OPTIONS
);
}
get model() {}
if (!hasInternalComponentManager(component)) {
deprecated(
"The `component` parameter provided to `helper.renderGlimmer` is invalid. It must be a valid Glimmer component. Using a template compiled via `ember-cli-htmlbars` is deprecated. Use the <template>...</template> syntax or replace it with a proper component.",
POST_STREAM_DEPRECATION_OPTIONS
);
}
/**
* @deprecated the widget rendering system was decommissioned
*/
getModel() {}
if (!this.widget.postContentsDestroyCallbacks) {
throw "renderGlimmer can only be used in the context of a post";
}
/**
* @deprecated the widget rendering system was decommissioned
*/
rawHtml() {}
const renderGlimmer = new RenderGlimmer(
this.widget,
targetElement,
component,
data
);
renderGlimmer.init();
this.widget.postContentsDestroyCallbacks.push(
renderGlimmer.destroy.bind(renderGlimmer)
);
return renderGlimmer.element;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
cooked() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
connect() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
renderGlimmer() {}
}
DecoratorHelper.prototype.h = h;
export default DecoratorHelper;
@@ -1,10 +1,8 @@
import renderTags from "discourse/lib/render-tags";
import RawHtml from "discourse/widgets/raw-html";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
// Right now it's RawHTML. Eventually it should emit nodes
export default class DiscourseTags extends RawHtml {
constructor(attrs) {
attrs.html = renderTags(attrs.topic, attrs);
super(attrs);
}
}
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,69 +1,8 @@
import { h } from "virtual-dom";
import DecoratorHelper from "discourse/widgets/decorator-helper";
import hbs from "discourse/widgets/hbs-compiler";
import PostCooked from "discourse/widgets/post-cooked";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
// glimmer-post-stream: has glimmer version
createWidget("post-link-arrow", {
tagName: "div.post-link-arrow",
template: hbs`
<a href={{attrs.shareUrl}} class="post-info arrow" title={{i18n "topic.jump_reply"}} aria-label={{i18n
"topic.jump_reply_aria" username=attrs.name
}}>
{{#if attrs.above}}
{{d-icon "arrow-up"}}
{{else}}
{{d-icon "arrow-down"}}
{{/if}}
{{i18n "topic.jump_reply_button"}}
</a>
`,
});
// glimmer-post-stream: has glimmer version
export default createWidget("embedded-post", {
tagName: "div.reply",
buildKey: (attrs) => `embedded-post-${attrs.id}`,
buildAttributes(attrs) {
const attributes = { "data-post-id": attrs.id };
if (this.state.role) {
attributes.role = this.state.role;
}
if (this.state["aria-label"]) {
attributes["aria-label"] = this.state["aria-label"];
}
return attributes;
},
html(attrs, state) {
attrs.embeddedPost = true;
return [
h("div.row", [
this.attach("post-avatar", attrs),
h("div.topic-body", [
h("div.topic-meta-data.embedded-reply", [
this.attach("poster-name", attrs),
this.attach("post-link-arrow", {
name: attrs.username,
above: state.above,
shareUrl: attrs.customShare,
}),
]),
new PostCooked(attrs, new DecoratorHelper(this), this.currentUser),
]),
]),
];
},
init() {
// TODO (glimmer-post-stream): How does this fit into the Glimmer lifecycle?
this.postContentsDestroyCallbacks = [];
},
destroy() {
this.postContentsDestroyCallbacks.forEach((c) => c());
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
+10 -23
View File
@@ -1,24 +1,11 @@
import { emojiUnescape, emojiUrlFor } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export function replaceEmoji(str) {
const escaped = emojiUnescape(escapeExpression(str));
return [new RawHtml({ html: `<span>${escaped}</span>` })];
}
export default createWidget("emoji", {
tagName: "img.emoji",
buildAttributes(attrs) {
let result = {
src: emojiUrlFor(attrs.name),
alt: `:${attrs.alt || attrs.name}:`,
};
if (attrs.title) {
result.title = typeof attrs.title === "string" ? attrs.title : attrs.name;
}
return result;
},
});
export {
warnWidgetsDecommissioned as replaceEmoji,
DummyWidget as default,
} from "discourse/widgets/widget";
+30 -60
View File
@@ -1,67 +1,37 @@
import { cancel, scheduleOnce } from "@ember/runloop";
import { diff, patch } from "virtual-dom";
import DirtyKeys from "discourse/lib/dirty-keys";
import { isTesting } from "discourse/lib/environment";
import { queryRegistry, traverseCustomWidgets } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
export default class WidgetGlue {
constructor(name, register, attrs) {
this._tree = null;
this._rootNode = null;
this.register = register;
this.attrs = attrs;
this._timeout = null;
this.dirtyKeys = new DirtyKeys(name);
this._widgetClass =
queryRegistry(name) || this.register.lookupFactory(`widget:${name}`);
if (!this._widgetClass) {
// eslint-disable-next-line no-console
console.error(`Error: Could not find widget: ${name}`);
}
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/ export default class WidgetGlue {
constructor() {
warnWidgetsDecommissioned();
}
appendTo(elem) {
this._rootNode = elem;
this.queueRerender();
}
/**
* @deprecated the widget rendering system was decommissioned
*/
appendTo() {}
queueRerender() {
this._timeout = scheduleOnce("render", this, this.rerenderWidget);
}
/**
* @deprecated the widget rendering system was decommissioned
*/
queueRerender() {}
rerenderWidget() {
cancel(this._timeout);
/**
* @deprecated the widget rendering system was decommissioned
*/
rerenderWidget() {}
// in test mode return early if store cannot be found
if (isTesting()) {
try {
this.register.lookup("service:store");
} catch {
return;
}
}
const newTree = new this._widgetClass(this.attrs, this.register, {
dirtyKeys: this.dirtyKeys,
});
const patches = diff(this._tree || this._rootNode, newTree);
traverseCustomWidgets(this._tree, (w) => w.willRerenderWidget());
newTree._rerenderable = this;
this._rootNode = patch(this._rootNode, patches);
this._tree = newTree;
traverseCustomWidgets(newTree, (w) => w.didRenderWidget());
}
cleanUp() {
traverseCustomWidgets(this._tree, (w) => w.destroy());
cancel(this._timeout);
this._rootNode = patch(this._rootNode, diff(this._tree, null));
this._tree = null;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
cleanUp() {}
}
@@ -1,4 +1,8 @@
export default function hbs() {
// eslint-disable-next-line no-console
console.log("Templates should be precompiled server side");
}
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export { warnWidgetsDecommissioned as default } from "discourse/widgets/widget";
+43 -373
View File
@@ -1,381 +1,51 @@
import { bind } from "@ember/runloop";
import $ from "jquery";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
const CLICK_ATTRIBUTE_NAME = "_discourse_click_widget";
const DOUBLE_CLICK_ATTRIBUTE_NAME = "_discourse_double_click_widget";
const CLICK_OUTSIDE_ATTRIBUTE_NAME = "_discourse_click_outside_widget";
const MOUSE_DOWN_OUTSIDE_ATTRIBUTE_NAME =
"_discourse_mouse_down_outside_widget";
const KEY_UP_ATTRIBUTE_NAME = "_discourse_key_up_widget";
const KEY_DOWN_ATTRIBUTE_NAME = "_discourse_key_down_widget";
const DRAG_ATTRIBUTE_NAME = "_discourse_drag_widget";
const INPUT_ATTRIBUTE_NAME = "_discourse_input_widget";
const CHANGE_ATTRIBUTE_NAME = "_discourse_change_widget";
const MOUSE_DOWN_ATTRIBUTE_NAME = "_discourse_mouse_down_widget";
const MOUSE_UP_ATTRIBUTE_NAME = "_discourse_mouse_up_widget";
const MOUSE_MOVE_ATTRIBUTE_NAME = "_discourse_mouse_move_widget";
const MOUSE_OVER_ATTRIBUTE_NAME = "_discourse_mouse_over_widget";
const MOUSE_OUT_ATTRIBUTE_NAME = "_discourse_mouse_out_widget";
const TOUCH_START_ATTRIBUTE_NAME = "_discourse_touch_start_widget";
const TOUCH_END_ATTRIBUTE_NAME = "_discourse_touch_end_widget";
const TOUCH_MOVE_ATTRIBUTE_NAME = "_discourse_touch_move_widget";
const POINTER_OVER_ATTRIBUTE_NAME = "_discourse_pointer_over_widget";
const POINTER_OUT_ATTRIBUTE_NAME = "_discourse_pointer_out_widget";
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
class WidgetBaseHook {
constructor(widget) {
this.widget = widget;
}
}
function buildHook(attributeName, setAttr) {
return class extends WidgetBaseHook {
hook(node) {
if (setAttr) {
node.setAttribute(setAttr, true);
}
node[attributeName] = this.widget;
}
unhook(node) {
if (setAttr) {
node.removeAttribute(setAttr, true);
}
node[attributeName] = null;
}
};
}
// For the majority of events, we register a single listener on the `<body>`, and then
// notify the relevant widget (if any) when the event fires (see setupDocumentCallback() below)
export const WidgetClickHook = buildHook(CLICK_ATTRIBUTE_NAME);
export const WidgetDoubleClickHook = buildHook(DOUBLE_CLICK_ATTRIBUTE_NAME);
export const WidgetClickOutsideHook = buildHook(
CLICK_OUTSIDE_ATTRIBUTE_NAME,
"data-click-outside"
);
export const WidgetMouseDownOutsideHook = buildHook(
MOUSE_DOWN_OUTSIDE_ATTRIBUTE_NAME,
"data-mouse-down-outside"
);
export const WidgetKeyUpHook = buildHook(KEY_UP_ATTRIBUTE_NAME);
export const WidgetKeyDownHook = buildHook(KEY_DOWN_ATTRIBUTE_NAME);
export const WidgetInputHook = buildHook(INPUT_ATTRIBUTE_NAME);
export const WidgetChangeHook = buildHook(CHANGE_ATTRIBUTE_NAME);
export const WidgetMouseUpHook = buildHook(MOUSE_UP_ATTRIBUTE_NAME);
export const WidgetMouseDownHook = buildHook(MOUSE_DOWN_ATTRIBUTE_NAME);
export const WidgetMouseMoveHook = buildHook(MOUSE_MOVE_ATTRIBUTE_NAME);
export const WidgetMouseOverHook = buildHook(MOUSE_OVER_ATTRIBUTE_NAME);
export const WidgetPointerOverHook = buildHook(POINTER_OVER_ATTRIBUTE_NAME);
export const WidgetPointerOutHook = buildHook(POINTER_OUT_ATTRIBUTE_NAME);
export const WidgetMouseOutHook = buildHook(MOUSE_OUT_ATTRIBUTE_NAME);
export const WidgetTouchEndHook = buildHook(TOUCH_END_ATTRIBUTE_NAME);
// `touchstart` and `touchmove` events are particularly performance sensitive because
// they block scrolling on mobile. Therefore we want to avoid registering global non-passive
// listeners for these events.
// Instead, the WidgetTouchStartHook and WidgetDragHook automatically register listeners on
// the specific widget DOM elements when required.
function touchStartHandler(e) {
return e.currentTarget[TOUCH_START_ATTRIBUTE_NAME].touchStart(e);
}
function touchMoveHandler(e) {
return e.currentTarget[TOUCH_MOVE_ATTRIBUTE_NAME].touchMove(e);
}
export class WidgetTouchStartHook extends WidgetBaseHook {
hook(node, propertyName, previousValue) {
node[TOUCH_START_ATTRIBUTE_NAME] = this.widget;
if (!previousValue) {
// Element added to DOM
node.addEventListener("touchstart", touchStartHandler, {
passive: false,
});
}
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
class DummyHook {
constructor() {
warnWidgetsDecommissioned();
}
unhook(node, propertyName, newValue) {
if (!newValue) {
// Element removed from DOM
node.removeEventListener("touchstart", touchStartHandler);
}
}
}
export class WidgetTouchMoveHook extends WidgetBaseHook {
hook(node, propertyName, previousValue) {
node[TOUCH_MOVE_ATTRIBUTE_NAME] = this.widget;
if (!previousValue) {
// Element added to DOM
node.addEventListener("touchmove", touchMoveHandler, {
passive: false,
});
}
}
/**
* @deprecated the widget rendering system was decommissioned
*/
hook() {}
unhook(node, propertyName, newValue) {
if (!newValue) {
// Element removed from DOM
node.removeEventListener("touchmove", touchMoveHandler);
}
}
/**
* @deprecated the widget rendering system was decommissioned
*/
unhook() {}
}
let _currentlyDraggingElement;
function dragStart(e) {
e.preventDefault();
e.stopPropagation();
if (_currentlyDraggingElement) {
dragEnd();
}
_currentlyDraggingElement = e.currentTarget;
document.body.classList.add("widget-dragging");
document.addEventListener("touchmove", drag, { passive: false });
document.addEventListener("mousemove", drag, { passive: false });
document.addEventListener("touchend", dragEnd);
document.addEventListener("mouseup", dragEnd);
}
function drag(e) {
const widget = _currentlyDraggingElement[DRAG_ATTRIBUTE_NAME];
if (e.type === "mousemove") {
widget.drag(e);
} else {
const tt = e.targetTouches[0];
e.preventDefault();
e.stopPropagation();
widget.drag(tt);
}
}
function dragEnd(e) {
document.body.classList.remove("widget-dragging");
document.removeEventListener("touchmove", drag);
document.removeEventListener("mousemove", drag);
document.removeEventListener("touchend", dragEnd);
document.removeEventListener("mouseup", dragEnd);
const widget = _currentlyDraggingElement[DRAG_ATTRIBUTE_NAME];
widget.dragEnd(e);
_currentlyDraggingElement = null;
}
export class WidgetDragHook extends WidgetBaseHook {
hook(node, propertyName, previousValue) {
node[DRAG_ATTRIBUTE_NAME] = this.widget;
if (!previousValue) {
// Adding to DOM
node.addEventListener("touchstart", dragStart, { passive: false });
node.addEventListener("mousedown", dragStart, { passive: false });
}
}
unhook(node, propertyName, newValue) {
if (!newValue) {
// Removing from DOM
if (_currentlyDraggingElement === node) {
dragEnd();
}
node.removeEventListener("touchstart", dragStart);
node.removeEventListener("mousedown", dragStart);
}
}
}
function nodeCallback(node, attrName, cb, options = { rerender: true }) {
const { rerender } = options;
const widget = findWidget(node, attrName);
if (widget) {
if (rerender) {
widget.rerenderResult(() => cb(widget));
} else {
cb(widget);
}
}
}
function findWidget(node, attrName) {
while (node) {
const widget = node[attrName];
if (widget) {
return widget;
}
node = node.parentNode;
}
}
let _watchingDocument = false;
WidgetClickHook.setupDocumentCallback = function () {
if (_watchingDocument) {
return;
}
$(document).on(
"mouseover.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, MOUSE_OVER_ATTRIBUTE_NAME, (w) => w.mouseOver(e), {
rerender: false,
});
})
);
$(document).on(
"pointerover.discourse-widget",
bind(this, (e) => {
nodeCallback(
e.target,
POINTER_OVER_ATTRIBUTE_NAME,
(w) => w.pointerOver(e),
{
rerender: false,
}
);
})
);
$(document).on(
"mouseout.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, MOUSE_OUT_ATTRIBUTE_NAME, (w) => w.mouseOut(e), {
rerender: false,
});
})
);
$(document).on(
"pointerout.discourse-widget",
bind(this, (e) => {
nodeCallback(
e.target,
POINTER_OUT_ATTRIBUTE_NAME,
(w) => w.pointerOut(e),
{
rerender: false,
}
);
})
);
$(document).on(
"dblclick.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, DOUBLE_CLICK_ATTRIBUTE_NAME, (w) =>
w.doubleClick(e)
);
})
);
$(document).on(
"click.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, CLICK_ATTRIBUTE_NAME, (w) => w.click(e));
let node = e.target;
const $outside = $("[data-click-outside]");
$outside.each((i, outNode) => {
if (
outNode.contains(node) ||
(outNode === node && outNode.style.position === "absolute")
) {
return;
}
const widget2 = outNode[CLICK_OUTSIDE_ATTRIBUTE_NAME];
if (widget2) {
widget2.clickOutside(e);
}
});
})
);
$(document).on(
"mousedown.discourse-widget",
bind(this, (e) => {
let node = e.target;
const $outside = $("[data-mouse-down-outside]");
$outside.each((i, outNode) => {
if (outNode.contains(node)) {
return;
}
const widget2 = outNode[MOUSE_DOWN_OUTSIDE_ATTRIBUTE_NAME];
if (widget2) {
widget2.mouseDownOutside(e);
}
});
})
);
$(document).on(
"keyup.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, KEY_UP_ATTRIBUTE_NAME, (w) => w.keyUp(e));
})
);
$(document).on(
"keydown.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, KEY_DOWN_ATTRIBUTE_NAME, (w) => w.keyDown(e));
})
);
$(document).on(
"input.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, INPUT_ATTRIBUTE_NAME, (w) => w.input(e), {
rerender: false,
});
})
);
$(document).on(
"change.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, CHANGE_ATTRIBUTE_NAME, (w) => w.change(e), {
rerender: false,
});
})
);
$(document).on(
"touchend.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, TOUCH_END_ATTRIBUTE_NAME, (w) => w.touchEnd(e), {
rerender: false,
});
})
);
$(document).on(
"mousedown.discourse-widget",
bind(this, (e) => {
nodeCallback(
e.target,
MOUSE_DOWN_ATTRIBUTE_NAME,
(w) => {
w.mouseDown(e);
},
{ rerender: false }
);
})
);
$(document).on(
"mouseup.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, MOUSE_UP_ATTRIBUTE_NAME, (w) => w.mouseUp(e), {
rerender: false,
});
})
);
$(document).on(
"mousemove.discourse-widget",
bind(this, (e) => {
nodeCallback(e.target, MOUSE_MOVE_ATTRIBUTE_NAME, (w) => w.mouseMove(e));
})
);
_watchingDocument = true;
export {
DummyHook as WidgetClickHook,
DummyHook as WidgetDoubleClickHook,
DummyHook as WidgetClickOutsideHook,
DummyHook as WidgetMouseDownOutsideHook,
DummyHook as WidgetKeyUpHook,
DummyHook as WidgetKeyDownHook,
DummyHook as WidgetInputHook,
DummyHook as WidgetChangeHook,
DummyHook as WidgetMouseUpHook,
DummyHook as WidgetMouseDownHook,
DummyHook as WidgetMouseMoveHook,
DummyHook as WidgetMouseOverHook,
DummyHook as WidgetPointerOverHook,
DummyHook as WidgetPointerOutHook,
DummyHook as WidgetMouseOutHook,
DummyHook as WidgetTouchEndHook,
DummyHook as WidgetTouchStartHook,
DummyHook as WidgetTouchMoveHook,
DummyHook as WidgetDragHook,
};
+7 -131
View File
@@ -1,132 +1,8 @@
import { h } from "virtual-dom";
import getURL from "discourse/lib/get-url";
import { iconNode } from "discourse/lib/icon-library";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL from "discourse/lib/url";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export default createWidget("link", {
tagName: "a",
href(attrs) {
if (attrs.route) {
const router = this.register.lookup("service:router");
if (attrs.model) {
return router.urlFor(attrs.route, attrs.model);
} else {
return router.urlFor(attrs.route);
}
} else {
return getURL(attrs.href);
}
},
buildClasses(attrs) {
const result = [];
result.push("widget-link");
if (attrs.className) {
result.push(attrs.className);
}
return result;
},
buildAttributes(attrs) {
const ret = {
href: this.href(attrs),
title: attrs.title
? i18n(attrs.title, attrs.titleOptions)
: this.label(attrs),
};
if (attrs.attributes) {
Object.keys(attrs.attributes).forEach(
(k) => (ret[k] = attrs.attributes[k])
);
}
return ret;
},
label(attrs) {
if (attrs.labelCount && attrs.count) {
return i18n(attrs.labelCount, { count: attrs.count });
}
return attrs.rawLabel || (attrs.label ? i18n(attrs.label) : "");
},
html(attrs) {
if (attrs.contents) {
return attrs.contents();
}
const result = [];
if (attrs.icon) {
if (attrs["aria-label"]) {
let icon = iconNode(attrs.icon);
icon.properties.attributes["aria-label"] = i18n(
attrs["aria-label"],
attrs.ariaLabelOptions
);
icon.properties.attributes["role"] = "img";
icon.properties.attributes["aria-hidden"] = false;
result.push(icon);
} else {
result.push(iconNode(attrs.icon));
}
result.push(" ");
}
if (!attrs.hideLabel) {
let label = this.label(attrs);
if (attrs.omitSpan) {
result.push(label);
} else {
result.push(h("span.d-label", label));
}
}
const currentUser = this.currentUser;
if (currentUser && attrs.badgeCount) {
const val = parseInt(currentUser.get(attrs.badgeCount), 10);
if (val > 0) {
const title = attrs.badgeTitle ? i18n(attrs.badgeTitle) : "";
result.push(" ");
result.push(
h(
"span.badge-notification",
{
className: attrs.badgeClass,
attributes: { title },
},
val
)
);
}
}
return result;
},
click(e) {
if (this.attrs.attributes && this.attrs.attributes.target === "_blank") {
return;
}
if (wantsNewWindow(e)) {
return;
}
e.preventDefault();
if (this.attrs.action) {
e.preventDefault();
return this.sendWidgetAction(this.attrs.action, this.attrs.actionParam);
} else {
this.sendWidgetEvent("linkClicked", this.attrs);
}
return DiscourseURL.routeToTag(e.target.closest("a"));
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
+7 -18
View File
@@ -1,19 +1,8 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
createWidget("menu-panel", {
tagName: "div.menu-panel",
template: hbs`
<div class='panel-body'>
<div class='panel-body-contents'>
{{yield}}
</div>
</div>
`,
buildAttributes(attrs) {
if (attrs.maxWidth) {
return { "data-max-width": attrs.maxWidth };
}
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
+111 -444
View File
@@ -1,448 +1,115 @@
import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { ajax } from "discourse/lib/ajax";
import { isValidLink } from "discourse/lib/click-track";
import escape from "discourse/lib/escape";
import { number } from "discourse/lib/formatter";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import getURL from "discourse/lib/get-url";
import highlightHTML, { unhighlightHTML } from "discourse/lib/highlight-html";
import highlightSearch from "discourse/lib/highlight-search";
import { iconHTML } from "discourse/lib/icon-library";
import { applyValueTransformer } from "discourse/lib/transformer";
import {
destroyUserStatusOnMentions,
updateUserStatusOnMention,
} from "discourse/lib/update-user-status-on-mention";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
let _decorators = [];
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
// Don't call this directly: use `plugin-api/decorateCookedElement`
export function addDecorator(callback) {
_decorators.push(callback);
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
class PostCooked {
constructor() {
warnWidgetsDecommissioned();
}
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
update() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
destroy() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_decorateAndAdopt() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_applySearchHighlight() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_showLinkCounts() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
async _toggleQuote() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_urlForPostNumber() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_updateQuoteElements() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_insertQuoteControls() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_computeCooked() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_decorateMentions() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_rerenderUserStatusOnMentions() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_rerenderUsersStatusOnMentions() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_extractMentions() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_trackMentionedUserStatus() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_stopTrackingMentionedUsersStatus() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
_post() {}
}
export function resetDecorators() {
_decorators = [];
}
let detachedDocument = document.implementation.createHTMLDocument("detached");
function createDetachedElement(nodeName) {
return detachedDocument.createElement(nodeName);
}
export default class PostCooked {
originalQuoteContents = null;
constructor(attrs, decoratorHelper, currentUser) {
this.attrs = attrs;
this.expanding = false;
this._highlighted = false;
this.decoratorHelper = decoratorHelper;
this.currentUser = currentUser;
this.ignoredUsers = this.currentUser
? this.currentUser.ignored_users
: null;
}
init() {
this.originalQuoteContents = null;
// todo should be a better way of detecting if it is composer preview
this._isInComposerPreview = !this.decoratorHelper;
this.cookedDiv = this._computeCooked();
this._insertQuoteControls(this.cookedDiv);
this._showLinkCounts(this.cookedDiv);
this._applySearchHighlight(this.cookedDiv);
this._decorateMentions();
this._decorateAndAdopt(this.cookedDiv);
return this.cookedDiv;
}
update(prev) {
if (
prev.attrs.cooked !== this.attrs.cooked ||
prev.attrs.highlightTerm !== this.attrs.highlightTerm
) {
return this.init();
}
}
destroy() {
this._stopTrackingMentionedUsersStatus();
destroyUserStatusOnMentions();
}
_decorateAndAdopt(cooked) {
_decorators.forEach((d) => d(cooked, this.decoratorHelper));
document.adoptNode(cooked);
}
_applySearchHighlight(html) {
const highlight = this.attrs.highlightTerm;
if (highlight && highlight.length > 2) {
if (this._highlighted) {
unhighlightHTML(html);
}
highlightSearch(html, highlight, { defaultClassName: true });
this._highlighted = true;
} else if (this._highlighted) {
unhighlightHTML(html);
this._highlighted = false;
}
}
_showLinkCounts(html) {
const linkCounts = this.attrs.linkCounts;
if (!linkCounts) {
return;
}
// find the best <a> element in each onebox and display link counts only
// for that one (the best element is the most significant one to the
// viewer)
const bestElements = new Map();
html.querySelectorAll("aside.onebox").forEach((onebox) => {
// look in headings first
for (let i = 1; i <= 6; ++i) {
const hLinks = onebox.querySelectorAll(`h${i} a[href]`);
if (hLinks.length > 0) {
bestElements.set(onebox, hLinks[0]);
return;
}
}
// use the header otherwise
const hLinks = onebox.querySelectorAll("header a[href]");
if (hLinks.length > 0) {
bestElements.set(onebox, hLinks[0]);
}
});
linkCounts.forEach((lc) => {
if (!lc.clicks || lc.clicks < 1) {
return;
}
html.querySelectorAll("a[href]").forEach((link) => {
const href = link.getAttribute("href");
let valid = href === lc.url;
// this might be an attachment
if (lc.internal && /^\/uploads\//.test(lc.url)) {
valid = href.includes(lc.url);
}
// match server-side behavior for internal links with query params
if (lc.internal && /\?/.test(href)) {
valid = href.split("?")[0] === lc.url;
}
// don't display badge counts on category badge & oneboxes (unless when explicitly stated)
if (valid && isValidLink(link)) {
const onebox = link.closest(".onebox");
if (
!onebox ||
!bestElements.has(onebox) ||
bestElements.get(onebox) === link
) {
link.setAttribute("data-clicks", number(lc.clicks));
const ariaLabel = `${link.textContent.trim()} ${i18n(
"post.link_clicked",
{
count: lc.clicks,
}
)}`;
link.setAttribute("aria-label", ariaLabel);
}
}
});
});
}
async _toggleQuote(aside) {
if (this.expanding) {
return;
}
this.expanding = true;
const blockQuote = aside.querySelector("blockquote");
if (!blockQuote) {
return;
}
if (aside.dataset.expanded) {
delete aside.dataset.expanded;
} else {
aside.dataset.expanded = true;
}
const quoteId = blockQuote.id;
if (aside.dataset.expanded) {
this._updateQuoteElements(aside, "chevron-up");
// Show expanded quote
this.originalQuoteContents.set(quoteId, blockQuote.innerHTML);
const originalText =
blockQuote.textContent.trim() ||
this.attrs.cooked.querySelector("blockquote").textContent.trim();
blockQuote.innerHTML = spinnerHTML;
const topicId = parseInt(aside.dataset.topic || this.attrs.topicId, 10);
const postId = parseInt(aside.dataset.post, 10);
try {
const result = await ajax(`/posts/by_number/${topicId}/${postId}`);
const post = this._post();
const quotedPosts = post.quoted || {};
quotedPosts[result.id] = result;
post.set("quoted", quotedPosts);
const div = createDetachedElement("div");
div.classList.add("expanded-quote");
div.dataset.postId = result.id;
div.innerHTML = result.cooked;
this._decorateAndAdopt(div);
highlightHTML(div, originalText, {
matchCase: true,
});
blockQuote.innerHTML = "";
blockQuote.appendChild(div);
} catch (e) {
if ([403, 404].includes(e.jqXHR.status)) {
const icon = e.jqXHR.status === 403 ? "lock" : "trash-can";
blockQuote.innerHTML = `<div class='expanded-quote icon-only'>${iconHTML(
icon
)}</div>`;
}
}
} else {
// Hide expanded quote
this._updateQuoteElements(aside, "chevron-down");
blockQuote.innerHTML = this.originalQuoteContents.get(blockQuote.id);
}
this.expanding = false;
}
_urlForPostNumber(postNumber) {
return postNumber > 0
? `${this.attrs.topicUrl}/${postNumber}`
: this.attrs.topicUrl;
}
_updateQuoteElements(aside, desc) {
const quoteTitle = i18n("post.follow_quote");
const postNumber = aside.dataset.post;
const topicNumber = aside.dataset.topic;
// If we have a post reference
let navLink = "";
if (
topicNumber &&
postNumber &&
topicNumber === this.attrs.topicId?.toString()
) {
const icon = iconHTML("arrow-up");
navLink = `<a href='${this._urlForPostNumber(
postNumber
)}' title='${quoteTitle}' aria-label='${quoteTitle}' class='btn-flat back'>${icon}</a>`;
}
// Only add the expand/contract control if it's not a full post
const titleElement = aside.querySelector(".title");
let expandContract = "";
if (!aside.dataset.full) {
const icon = iconHTML(desc);
const quoteId = aside.querySelector("blockquote")?.id;
if (quoteId) {
const isExpanded = aside.dataset.expanded === "true";
const toggleLabel = isExpanded ? i18n("post.collapse") : i18n("expand");
expandContract = `<button aria-controls="${quoteId}" aria-expanded="${isExpanded}" aria-label="${toggleLabel}" title="${toggleLabel}" class="quote-toggle btn-flat">${icon}</button>`;
if (titleElement) {
titleElement.style.cursor = "pointer";
}
}
}
if (this.ignoredUsers?.length && titleElement) {
const username = titleElement.innerText.trim().slice(0, -1);
if (username.length > 0 && this.ignoredUsers.includes(username)) {
aside.querySelectorAll("p").forEach((el) => el.remove());
aside.classList.add("ignored-user");
}
}
const quoteControls = aside.querySelector(".quote-controls");
if (quoteControls) {
quoteControls.innerHTML = expandContract + navLink;
}
}
_insertQuoteControls(html) {
const quotes = html.querySelectorAll("aside.quote");
if (quotes.length === 0) {
return;
}
this.originalQuoteContents = new Map();
quotes.forEach((aside, index) => {
if (aside.dataset.post) {
const quoteId = `quote-id-${aside.dataset.topic}-${aside.dataset.post}-${index}`;
const blockquote = aside.querySelector("blockquote");
if (blockquote) {
blockquote.id = quoteId;
}
this._updateQuoteElements(aside, "chevron-down");
const title = aside.querySelector(".title");
if (!title) {
return;
}
// If post/topic is not found then display username, skip controls
if (aside.classList.contains("quote-post-not-found")) {
if (aside.dataset.username) {
title.innerHTML = escape(aside.dataset.username);
} else {
title.remove();
}
return;
}
// Unless it's a full quote, allow click to expand
if (!aside.dataset.full && !title.dataset.hasQuoteControls) {
title.addEventListener("click", (e) => {
if (e.target.closest("a")) {
return true;
}
this._toggleQuote(aside);
});
title.dataset.hasQuoteControls = true;
}
}
});
}
_computeCooked() {
const cookedDiv = createDetachedElement("div");
cookedDiv.classList.add("cooked");
if (
(this.attrs.firstPost || this.attrs.embeddedPost) &&
this.ignoredUsers?.includes?.(this.attrs.username)
) {
cookedDiv.classList.add("post-ignored");
cookedDiv.innerHTML = i18n("post.ignored");
} else {
cookedDiv.innerHTML = this.attrs.cooked;
}
// On WebKit-based browsers, triple clicking on the last paragraph of a post won't stop at the end of the paragraph.
// It looks like the browser is selecting EOL characters, and that causes the selection to leak into the following
// nodes until it finds a non-empty node. This is a workaround to prevent that from happening.
// We insert a div after the last paragraph at the end of the cooked content, containing a <br> element.
// The line break works as a barrier, causing the selection to stop at the correct place.
// To prevent layout shifts this div is styled to be invisible with height 0 and overflow hidden and set aria-hidden
// to true to prevent screen readers from reading it.
const selectionBarrier = document.createElement("div");
selectionBarrier.classList.add("cooked-selection-barrier");
selectionBarrier.ariaHidden = "true";
selectionBarrier.appendChild(document.createElement("br"));
cookedDiv.appendChild(selectionBarrier);
return cookedDiv;
}
_decorateMentions() {
if (!this._isInComposerPreview) {
destroyUserStatusOnMentions();
}
this._extractMentions().forEach(({ mentions, user }) => {
if (!this._isInComposerPreview) {
this._trackMentionedUserStatus(user);
this._rerenderUserStatusOnMentions(mentions, user);
}
const classes = applyValueTransformer("mentions-class", [], {
user,
});
mentions.forEach((mention) => {
mention.classList.add(...classes);
});
});
}
_rerenderUserStatusOnMentions(mentions, user) {
mentions.forEach((mention) => {
updateUserStatusOnMention(
getOwnerWithFallback(this),
mention,
user.status
);
});
}
_rerenderUsersStatusOnMentions() {
this._extractMentions().forEach(({ mentions, user }) => {
this._rerenderUserStatusOnMentions(mentions, user);
});
}
_extractMentions() {
return (
this._post()?.mentioned_users?.map((user) => {
const href = getURL(`/u/${user.username.toLowerCase()}`);
const mentions = this.cookedDiv.querySelectorAll(
`a.mention[href="${href}"]`
);
return { user, mentions };
}) || []
);
}
_trackMentionedUserStatus(user) {
user.statusManager?.trackStatus?.();
user.on?.("status-changed", this, "_rerenderUsersStatusOnMentions");
}
_stopTrackingMentionedUsersStatus() {
this._post()?.mentioned_users?.forEach((user) => {
user.statusManager?.stopTrackingStatus?.();
user.off?.("status-changed", this, "_rerenderUsersStatusOnMentions");
});
}
_post() {
return this.decoratorHelper?.getModel?.();
}
}
PostCooked.prototype.type = "Widget";
export {
warnWidgetsDecommissioned as addDecorator,
warnWidgetsDecommissioned as resetDecorators,
PostCooked as default,
};
@@ -1,47 +1,8 @@
import { historyHeat } from "discourse/components/post/meta-data/edits-indicator";
import { longDate } from "discourse/lib/formatter";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
// glimmer-post-stream: has glimmer version
export default createWidget("post-edits-indicator", {
tagName: "div.post-info.edits",
html(attrs) {
let icon = "pencil";
const updatedAt = new Date(attrs.updated_at);
let className = historyHeat(this.siteSettings, updatedAt);
const date = longDate(updatedAt);
let title;
if (attrs.wiki) {
icon = "far-pen-to-square";
className = `${className || ""} wiki`.trim();
if (attrs.version > 1) {
title = i18n("post.wiki_last_edited_on", { dateTime: date });
} else {
title = i18n("post.wiki.about");
}
} else {
title = i18n("post.last_edited_on", { dateTime: date });
}
return this.attach("flat-button", {
icon,
translatedTitle: title,
className,
action: "onPostEditsIndicatorClick",
translatedAriaLabel: i18n("post.edit_history"),
translatedLabel: attrs.version > 1 ? attrs.version - 1 : "",
});
},
onPostEditsIndicatorClick() {
if (this.attrs.wiki && this.attrs.version === 1) {
this.sendWidgetAction("editPost");
} else if (this.attrs.canViewEditHistory) {
this.sendWidgetAction("showHistory");
}
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
+7 -36
View File
@@ -1,37 +1,8 @@
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export default createWidget("post-gap", {
tagName: "div.gap",
buildKey: (attrs) => `post-gap-${attrs.pos}-${attrs.postId}`,
defaultState() {
return { loading: false };
},
html(attrs, state) {
return state.loading
? i18n("loading")
: i18n("post.gap", { count: attrs.gap.length });
},
click() {
const { attrs, state } = this;
if (state.loading) {
return;
}
state.loading = true;
const args = { gap: attrs.gap, post: this.model };
return this.sendWidgetAction(
attrs.pos === "before" ? "fillGapBefore" : "fillGapAfter",
args
).then(() => {
state.loading = false;
this.appEvents.trigger("post-stream:gap-expanded", {
post_id: this.model.id,
});
});
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
+7 -88
View File
@@ -1,89 +1,8 @@
import { h } from "virtual-dom";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { iconNode } from "discourse/lib/icon-library";
import { replaceEmoji } from "discourse/widgets/emoji";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export default createWidget("post-links", {
tagName: "div.post-links-container",
buildKey: (attrs) => `post-links-${attrs.id}`,
defaultState() {
return { collapsed: true };
},
linkHtml(link) {
const linkBody = replaceEmoji(link.title);
const attributes = {
href: link.url,
};
if (link.clicks) {
attributes["data-clicks"] = link.clicks.toString();
}
return h(
"li",
h(
"a.track-link",
{
className: "inbound",
attributes,
},
[iconNode("link"), linkBody]
)
);
},
html(attrs, state) {
if (!this.attrs.links || this.attrs.links.length === 0) {
// shortcut all work
return;
}
// only show incoming
const links = uniqueItemsFromArray(
this.attrs.links.filter((l) => l.reflection),
"title"
);
if (links.length === 0) {
return;
}
const result = [];
// show all links
if (links.length <= 5 || !state.collapsed) {
links.forEach((l) => result.push(this.linkHtml(l)));
} else {
const max = Math.min(5, links.length);
for (let i = 0; i < max; i++) {
result.push(this.linkHtml(links[i]));
}
// 'show more' link
if (links.length > max) {
result.push(
h(
"li",
this.attach("link", {
labelCount: "post_links.title",
title: "post_links.about",
count: links.length - max,
action: "expandLinks",
className: "expand-links",
})
)
);
}
}
if (result.length) {
return h("ul.post-links", result);
}
},
expandLinks() {
this.state.collapsed = false;
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,19 +1,8 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
// glimmer-post-stream: has glimmer version
export default createWidget("post-placeholder", {
tagName: "article.placeholder",
template: hbs`
<div class='row'>
<div class='topic-avatar'>
<div class='placeholder-avatar placeholder-animation'></div>
</div>
<div class='topic-body'>
<div class='placeholder-text placeholder-animation'></div>
<div class='placeholder-text placeholder-animation'></div>
<div class='placeholder-text placeholder-animation'></div>
</div>
</div>
`,
});
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,187 +1,15 @@
import { computed } from "@ember/object";
import { htmlSafe } from "@ember/template";
import { h } from "virtual-dom";
import {
customGroupActionCodes,
GROUP_ACTION_CODES,
ICONS,
} from "discourse/components/post/small-action";
import { autoUpdatingRelativeAge } from "discourse/lib/formatter";
import { iconNode } from "discourse/lib/icon-library";
import { userPath } from "discourse/lib/url";
import DecoratorHelper from "discourse/widgets/decorator-helper";
import { avatarFor } from "discourse/widgets/post";
import PostCooked from "discourse/widgets/post-cooked";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export function actionDescriptionHtml(actionCode, createdAt, username, path) {
const when = createdAt
? autoUpdatingRelativeAge(new Date(createdAt), {
format: "medium-with-ago-and-on",
})
: "";
let who = "";
if (username) {
if (
GROUP_ACTION_CODES.includes(actionCode) ||
customGroupActionCodes.includes(actionCode)
) {
who = `<a class="mention-group" href="/g/${username}">@${username}</a>`;
} else {
who = `<a class="mention" href="${userPath(username)}">@${username}</a>`;
}
}
return htmlSafe(i18n(`action_codes.${actionCode}`, { who, when, path }));
}
export function actionDescription(
actionCode,
createdAt,
username,
path = null
) {
return computed(actionCode, createdAt, function () {
const ac = this.get(actionCode);
if (ac) {
return actionDescriptionHtml(
ac,
this.get(createdAt),
this.get(username),
path ? this.get(path) : null
);
}
});
}
const addPostSmallActionClassesCallbacks = [];
export function addPostSmallActionIcon(key, icon) {
ICONS[key] = icon;
}
export function addPostSmallActionClassesCallback(callback) {
addPostSmallActionClassesCallbacks.push(callback);
}
export function resetPostSmallActionClassesCallbacks() {
addPostSmallActionClassesCallbacks.length = 0;
}
// glimmer-post-stream: has glimmer version
export default createWidget("post-small-action", {
buildKey: (attrs) => `post-small-act-${attrs.id}`,
tagName: "article.small-action.onscreen-post",
buildAttributes(attrs) {
return {
"aria-label": i18n("share.post", {
postNumber: attrs.post_number,
username: attrs.username,
}),
role: "region",
};
},
buildId(attrs) {
return `post_${attrs.post_number}`;
},
buildClasses(attrs) {
let classNames = [];
if (attrs.deleted) {
classNames.push("deleted");
}
if (addPostSmallActionClassesCallbacks.length > 0) {
addPostSmallActionClassesCallbacks.forEach((callback) => {
const additionalClasses = callback.call(this, attrs);
if (additionalClasses) {
classNames.push(...additionalClasses);
}
});
}
return classNames;
},
html(attrs) {
const contents = [];
const buttons = [];
contents.push(
avatarFor.call(this, "small", {
template: attrs.avatar_template,
username: attrs.username,
url: attrs.usernameUrl,
ariaHidden: false,
})
);
if (attrs.actionDescriptionWidget) {
contents.push(this.attach(attrs.actionDescriptionWidget, attrs));
} else {
const description = actionDescriptionHtml(
attrs.actionCode,
new Date(attrs.created_at),
attrs.actionCodeWho,
attrs.actionCodePath
);
contents.push(new RawHtml({ html: `<p>${description}</p>` }));
}
if (attrs.canRecover) {
buttons.push(
this.attach("button", {
className: "btn-flat small-action-recover",
icon: "arrow-rotate-left",
action: "recoverPost",
title: "post.controls.undelete",
})
);
}
if (attrs.canEdit && !attrs.canRecover) {
buttons.push(
this.attach("button", {
className: "btn-flat small-action-edit",
icon: "pencil",
action: "editPost",
title: "post.controls.edit",
})
);
}
if (attrs.canDelete) {
buttons.push(
this.attach("button", {
className: "btn-flat btn-danger small-action-delete",
icon: "trash-can",
action: "deletePost",
title: "post.controls.delete",
})
);
}
return [
h("div.topic-avatar", iconNode(ICONS[attrs.actionCode] || "exclamation")),
h("div.small-action-desc", [
h("div.small-action-contents", contents),
h("div.small-action-buttons", buttons),
!attrs.actionDescriptionWidget && attrs.cooked
? h("div.small-action-custom-message", [
new PostCooked(
attrs,
new DecoratorHelper(this),
this.currentUser
),
])
: null,
]),
];
},
});
export {
warnWidgetsDecommissioned as actionDescriptionHtml,
warnWidgetsDecommissioned as actionDescription,
warnWidgetsDecommissioned as addPostSmallActionIcon,
warnWidgetsDecommissioned as addPostSmallActionClassesCallback,
warnWidgetsDecommissioned as resetPostSmallActionClassesCallbacks,
DummyWidget as default,
} from "discourse/widgets/widget";
+15 -347
View File
@@ -1,348 +1,16 @@
import { hbs } from "ember-cli-htmlbars";
import $ from "jquery";
import { h } from "virtual-dom";
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import discourseDebounce from "discourse/lib/debounce";
import { registerDeprecationHandler } from "discourse/lib/deprecated";
import { iconNode } from "discourse/lib/icon-library";
import { consolePrefix } from "discourse/lib/source-identifier";
import transformPost from "discourse/lib/transform-post";
import DiscourseURL from "discourse/lib/url";
import { Placeholder } from "discourse/models/post-stream";
import { avatarFor } from "discourse/widgets/post";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import {
createWidget,
POST_STREAM_DEPRECATION_OPTIONS,
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export {
warnWidgetsDecommissioned as postTransformCallbacks,
warnWidgetsDecommissioned as addPostTransformCallback,
warnWidgetsDecommissioned as disableCloaking,
warnWidgetsDecommissioned as preventCloak,
warnWidgetsDecommissioned as cloak,
warnWidgetsDecommissioned as uncloak,
DummyWidget as default,
} from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
export let havePostStreamWidgetExtensions = null;
registerDeprecationHandler((_, opts) => {
if (opts?.id === POST_STREAM_DEPRECATION_OPTIONS.id) {
if (!havePostStreamWidgetExtensions) {
havePostStreamWidgetExtensions = new Set();
}
havePostStreamWidgetExtensions.add(consolePrefix().slice(1, -1));
}
});
let transformCallbacks = null;
export function postTransformCallbacks(transformed) {
if (transformCallbacks === null) {
return;
}
for (let i = 0; i < transformCallbacks.length; i++) {
transformCallbacks[i].call(this, transformed);
}
}
export function addPostTransformCallback(callback) {
transformCallbacks = transformCallbacks || [];
transformCallbacks.push(callback);
}
let _enabled = true;
const DAY = 1000 * 60 * 60 * 24;
const _dontCloak = {};
let _cloaked = {};
let _heights = {};
export function disableCloaking() {
_enabled = false;
}
export function preventCloak(postId) {
_dontCloak[postId] = true;
}
export function cloak(post, component) {
if (!_enabled || _cloaked[post.id] || _dontCloak[post.id]) {
return;
}
const $post = $(`#post_${post.post_number}`).parent();
_cloaked[post.id] = true;
_heights[post.id] = $post.outerHeight();
component.dirtyKeys.keyDirty(`post-${post.id}`);
discourseDebounce(component, "queueRerender", 1000);
}
export function uncloak(post, component) {
if (!_enabled || !_cloaked[post.id]) {
return;
}
_cloaked[post.id] = null;
component.dirtyKeys.keyDirty(`post-${post.id}`);
component.queueRerender();
}
addWidgetCleanCallback("post-stream", () => {
_cloaked = {};
_heights = {};
});
// glimmer-post-stream: has glimmer version
createWidget("posts-filtered-notice", {
buildKey: (attrs) => `posts-filtered-notice-${attrs.id}`,
buildClasses() {
return ["posts-filtered-notice"];
},
html(attrs) {
const filters = attrs.streamFilters;
if (filters.filter_upwards_post_id || filters.mixedHiddenPosts) {
return [
h(
"span.filtered-replies-viewing",
i18n("post.filtered_replies.viewing_subset")
),
this.attach("filter-show-all", attrs),
];
} else if (filters.replies_to_post_number) {
const sourcePost = attrs.posts.find(
(item) => item.post_number === filters.replies_to_post_number
);
return [
h(
"span.filtered-replies-viewing",
i18n("post.filtered_replies_viewing", {
count: sourcePost.reply_count,
})
),
h("span.filtered-user-row", [
h(
"span.filtered-avatar",
avatarFor.call(this, "small", {
template: sourcePost.avatar_template,
username: sourcePost.username,
url: sourcePost.usernameUrl,
})
),
this.attach("filter-jump-to-post", {
username: sourcePost.username,
postNumber: filters.replies_to_post_number,
}),
]),
this.attach("filter-show-all", attrs),
];
} else if (filters.filter && filters.filter === "summary") {
return [
h(
"span.filtered-replies-viewing",
i18n("post.filtered_replies.viewing_summary")
),
this.attach("filter-show-all", attrs),
];
} else if (filters.username_filters) {
const firstUserPost = attrs.posts[1],
userPostsCount = parseInt(attrs.filteredPostsCount, 10) - 1;
return [
h(
"span.filtered-replies-viewing",
i18n("post.filtered_replies.viewing_posts_by", {
post_count: userPostsCount,
})
),
h(
"span.filtered-avatar",
avatarFor.call(this, "small", {
template: firstUserPost.avatar_template,
username: firstUserPost.username,
url: firstUserPost.usernameUrl,
})
),
this.attach("poster-name", firstUserPost),
this.attach("filter-show-all", attrs),
];
}
return [];
},
});
// glimmer-post-stream: has glimmer version
createWidget("filter-jump-to-post", {
tagName: "a.filtered-jump-to-post",
buildKey: (attrs) => `jump-to-post-${attrs.id}`,
html(attrs) {
return i18n("post.filtered_replies.post_number", {
username: attrs.username,
post_number: attrs.postNumber,
});
},
click() {
DiscourseURL.jumpToPost(this.attrs.postNumber);
},
});
// glimmer-post-stream: has glimmer version
createWidget("filter-show-all", {
tagName: "button.filtered-replies-show-all",
buildKey: (attrs) => `filtered-show-all-${attrs.id}`,
buildClasses() {
return ["btn", "btn-primary"];
},
html() {
return [iconNode("up-down"), i18n("post.filtered_replies.show_all")];
},
click() {
this.sendWidgetAction("cancelFilter");
this.appEvents.trigger(
"post-stream:filter-show-all",
this.attrs.streamFilters
);
},
});
export default createWidget("post-stream", {
tagName: "div.post-stream.widget-post-stream",
html(attrs) {
const postArray = attrs.posts || [];
const postArrayLength = postArray.length;
const maxPostNumber =
postArrayLength > 0 ? postArray[postArrayLength - 1].post_number : 0;
const result = [];
const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {};
const after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {};
const mobileView = this.site.mobileView;
let prevPost;
let prevDate;
for (let i = 0; i < postArrayLength; i++) {
const post = postArray[i];
if (post instanceof Placeholder) {
result.push(this.attach("post-placeholder"));
continue;
}
const nextPost = i < postArray.length - 1 ? postArray[i + 1] : null;
const transformed = transformPost(
this.currentUser,
this.site,
post,
prevPost,
nextPost
);
transformed.canCreatePost = attrs.canCreatePost;
transformed.prevPost = prevPost;
transformed.nextPost = nextPost;
transformed.mobileView = mobileView;
if (transformed.canManage || transformed.canSplitMergeTopic) {
transformed.multiSelect = attrs.multiSelect;
if (attrs.multiSelect) {
transformed.selected = attrs.selectedQuery(post);
}
}
if (attrs.searchService) {
transformed.highlightTerm = attrs.searchService.highlightTerm;
}
// Post gap - before
const beforeGap = before[post.id];
if (beforeGap) {
result.push(
this.attach(
"post-gap",
{ pos: "before", postId: post.id, gap: beforeGap },
{ model: post }
)
);
}
// Handle time gaps
const curTime = new Date(transformed.created_at).getTime();
if (prevDate) {
const daysSince = Math.floor((curTime - prevDate) / DAY);
if (daysSince > this.siteSettings.show_time_gap_days) {
result.push(
new RenderGlimmer(
this,
"div.time-gap",
hbs`<Post::TimeGap @daysSince={{@data.daysSince}} />`,
{ daysSince }
)
);
}
}
prevDate = curTime;
transformed.height = _heights[post.id];
transformed.cloaked = _cloaked[post.id];
postTransformCallbacks(transformed);
if (transformed.isSmallAction) {
result.push(
this.attach("post-small-action", transformed, { model: post })
);
} else {
transformed.showReadIndicator = attrs.showReadIndicator;
result.push(this.attach("post", transformed, { model: post }));
}
// Post gap - after
const afterGap = after[post.id];
if (afterGap) {
result.push(
this.attach(
"post-gap",
{ pos: "after", postId: post.id, gap: afterGap },
{ model: post }
)
);
}
if (
i !== postArrayLength - 1 &&
maxPostNumber <= attrs.highestPostNumber &&
attrs.lastReadPostNumber === post.post_number
) {
result.push(
this.attach("topic-post-visited-line", {
post_number: post.post_number,
})
);
}
prevPost = post;
}
if (
attrs.streamFilters &&
Object.keys(attrs.streamFilters).length &&
(Object.keys(before).length > 0 || Object.keys(after).length > 0)
) {
result.push(
this.attach("posts-filtered-notice", {
posts: postArray,
streamFilters: attrs.streamFilters,
filteredPostsCount: attrs.filteredPostsCount,
})
);
}
return result;
},
});
@@ -1,20 +1,8 @@
import { hbs } from "ember-cli-htmlbars";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { createWidget } from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
createWidget("post-user-status", {
tagName: "span.user-status-message-wrap",
html(attrs) {
return [
new RenderGlimmer(
this,
"span",
hbs`<UserStatusMessage @status={{@data.attrs}} />`,
{
attrs,
}
),
];
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,15 +0,0 @@
import { hbs } from "ember-cli-htmlbars";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
registerWidgetShim(
"post-user-tip-shim",
"div.post-user-tip-shim",
hbs`<UserTip
@id="post_menu"
@triggerSelector=".post-controls .actions .show-more-actions"
@placement="top"
@titleText={{i18n "user_tips.post_menu.title"}}
@contentText={{i18n "user_tips.post_menu.content"}}
@priority={{600}}
/>`
);
File diff suppressed because it is too large Load Diff
+10 -234
View File
@@ -1,235 +1,11 @@
import { hbs } from "ember-cli-htmlbars";
import { h } from "virtual-dom";
import getURL from "discourse/lib/get-url";
import { iconNode } from "discourse/lib/icon-library";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { applyValueTransformer } from "discourse/lib/transformer";
import { formatUsername } from "discourse/lib/utilities";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { applyDecorators, createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
let sanitizeName = function (name) {
return name.toLowerCase().replace(/[\s\._-]/g, "");
};
export function disableNameSuppression() {
sanitizeName = (name) => name;
}
// glimmer-post-stream: has glimmer version
createWidget("poster-name-title", {
tagName: "span.user-title",
buildClasses(attrs) {
let classNames = [];
classNames.push(attrs.title);
if (attrs.titleIsGroup) {
classNames.push(attrs.primaryGroupName);
}
classNames = classNames.map(
(className) =>
`user-title--${className.replace(/\s+/g, "-").toLowerCase()}`
);
return classNames;
},
html(attrs) {
let titleContents = attrs.title;
if (attrs.primaryGroupName && attrs.titleIsGroup) {
const href = getURL(`/g/${attrs.primaryGroupName}`);
titleContents = h(
"a.user-group",
{
className: attrs.extraClasses,
attributes: { href, "data-group-card": attrs.primaryGroupName },
},
attrs.title
);
}
return titleContents;
},
});
// glimmer-post-stream: has glimmer version
export default createWidget("poster-name", {
tagName: "div.names.trigger-user-card",
settings: {
showNameAndGroup: true,
showGlyph: true,
},
didRenderWidget() {
if (this.attrs.user) {
this.attrs.user.statusManager.trackStatus();
this.attrs.user.on("status-changed", this, "scheduleRerender");
}
},
willRerenderWidget() {
if (this.attrs.user) {
this.attrs.user.off("status-changed", this, "scheduleRerender");
this.attrs.user.statusManager.stopTrackingStatus();
}
},
// TODO: Allow extensibility
posterGlyph(attrs) {
if (attrs.moderator || attrs.groupModerator) {
return iconNode("shield-halved", {
title: i18n("user.moderator_tooltip"),
});
}
},
userLink(attrs, text) {
return h(
"a",
{
attributes: {
href: attrs.usernameUrl,
"data-user-card": attrs.username,
class: `${
this.siteSettings.hide_user_profiles_from_public &&
!this.currentUser
? "non-clickable"
: ""
}`,
},
},
formatUsername(text)
);
},
additionalClasses(attrs) {
return applyValueTransformer("poster-name-class", [], {
user: attrs.user,
});
},
html(attrs) {
const username = attrs.username;
const name = attrs.name;
const nameFirst =
this.siteSettings.display_name_on_posts && prioritizeNameInUx(name);
const classNames = nameFirst
? ["first", "full-name"]
: ["first", "username"];
if (attrs.staff) {
classNames.push("staff");
}
if (attrs.admin) {
classNames.push("admin");
}
if (attrs.moderator) {
classNames.push("moderator");
}
if (attrs.groupModerator) {
classNames.push("category-moderator");
}
if (attrs.new_user) {
classNames.push("new-user");
}
classNames.push(...this.additionalClasses(attrs));
const primaryGroupName = attrs.primary_group_name;
if (primaryGroupName && primaryGroupName.length) {
classNames.push(`group--${primaryGroupName}`);
}
let nameContents = [this.userLink(attrs, nameFirst ? name : username)];
if (this.settings.showGlyph) {
const glyph = this.posterGlyph(attrs);
if (glyph) {
nameContents.push(glyph);
}
}
const afterNameContents =
applyDecorators(this, "after-name", attrs, this.state) || [];
nameContents = nameContents.concat(afterNameContents);
const contents = [
h("span", { className: classNames.join(" ") }, nameContents),
];
if (this.settings.showNameAndGroup) {
if (
name &&
this.siteSettings.display_name_on_posts &&
sanitizeName(name) !== sanitizeName(username)
) {
contents.push(
h(
"span.second." + (nameFirst ? "username" : "full-name"),
[this.userLink(attrs, nameFirst ? username : name)].concat(
afterNameContents
)
)
);
}
this.buildTitleObject(attrs, contents);
if (this.siteSettings.enable_user_status) {
this.addUserStatus(contents, attrs);
}
}
if (attrs.badgesGranted?.length) {
const badges = [];
attrs.badgesGranted.forEach((badge) => {
// Alter the badge description to show that the badge was granted for this post.
badge.description = i18n("post.badge_granted_tooltip", {
username: attrs.username,
badge_name: badge.name,
});
const badgeIcon = new RenderGlimmer(
this,
`span.user-badge-button-${badge.slug}`,
hbs`<UserBadge @badge={{@data.badge}} @user={{@data.user}} @showName={{false}} />`,
{
badge,
user: attrs.user,
}
);
badges.push(badgeIcon);
});
contents.push(h("span.user-badge-buttons", badges));
}
return contents;
},
buildTitleObject(attrs, contents) {
const primaryGroupName = attrs.primary_group_name;
const title = attrs.user_title,
titleIsGroup = attrs.title_is_group;
if (title && title.length) {
contents.push(
this.attach("poster-name-title", {
title,
primaryGroupName,
titleIsGroup,
})
);
}
},
addUserStatus(contents, attrs) {
if (attrs.user && attrs.user.status) {
contents.push(this.attach("post-user-status", attrs.user.status));
}
},
});
export {
warnWidgetsDecommissioned as disableNameSuppression,
DummyWidget as default,
} from "discourse/widgets/widget";
+29 -16
View File
@@ -1,26 +1,39 @@
import $ from "jquery";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export default class RawHtml {
constructor(attrs) {
this.html = attrs.html;
constructor() {
warnWidgetsDecommissioned();
}
init() {
const $html = $(this.html);
this.decorate($html);
return $html[0];
}
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
decorate() {}
update(prev) {
if (prev.html === this.html) {
return;
}
return this.init();
}
/**
* @deprecated the widget rendering system was decommissioned
*/
update() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
destroy() {}
}
RawHtml.prototype.type = "Widget";
+39 -248
View File
@@ -1,260 +1,51 @@
import { hasInternalComponentManager } from "@glimmer/manager";
import { tracked } from "@glimmer/tracking";
import { setComponentTemplate } from "@ember/component";
import templateOnly from "@ember/component/template-only";
import { assert } from "@ember/debug";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import {
createWidgetFrom,
warnWidgetsDeprecation,
} from "discourse/widgets/widget";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
const INITIAL_CLASSES = Symbol("RENDER_GLIMMER_INITIAL_CLASSES");
/*
This class allows you to render arbitrary Glimmer templates inside widgets.
That glimmer template can include Classic and/or Glimmer components.
Example usage:
```
import { hbs } from "ember-cli-htmlbars";
// NOTE: If your file is already importing the `hbs` helper from "discourse/widgets/hbs-compiler"
// you'll need to rename that import to `import widgetHbs from "discourse/widgets/hbs-compiler"`
// before adding the `ember-cli-htmlbars` import.
...
// (inside an existing widget)
html(){
return [
new RenderGlimmer(
this,
"div.my-wrapper-class",
hbs`<MyComponent @arg1={{@data.arg1}} />`,
{
arg1: "some argument value"
}
),
]
}
```
You can also include function references in the `data` object, and use them as actions within the Ember component.
You will need to `bind` the function to ensure it maintains a reference to the widget, and you'll need to manually
call `this.scheduleRerender()` after making any changes to widget state (the normal widget auto-rerendering does not apply).
Note that the @bind decorator will only work if you're using class-based Widget syntax. When using createWidget, you'll need to
call `.bind(this)` manually when passing the function to RenderGlimmer.
For example:
```
createWidget("my-widget", {
tagName: "div",
buildKey: () => `my-widget`,
defaultState() {
return { counter: 0 };
},
html(args, state){
return [
new RenderGlimmer(
this,
"div.my-wrapper-class",
hbs`<MyComponent @counter={{@data.counter}} @incrementCounter={{@data.incrementCounter}} />`,
{
counter: state.counter,
incrementCounter: this.incrementCounter.bind(this),
}
),
]
},
incrementCounter() {
this.state.counter++;
this.scheduleRerender();
},
});
To dynamically control the attributes of the wrapper element, a helper function is provided as an argument to your hbs template.
To use this via a template, you can do something like this:
```
hbs`{{@setWrapperElementAttrs class="some class value" title="title value"}}`
```
If you prefer, you can pass this function down into your own components, and call it from there. Invoked as a helper, this can
be passed (auto-)tracked values, and will update the wrapper element attributes whenever the inputs.
*/
export default class RenderGlimmer {
/**
* Create a RenderGlimmer instance
* @param widget - the widget instance which is rendering this content
* @param renderInto - a string describing a new wrapper element (e.g. `div.my-class`),
* or an existing HTML element to append content into.
* @param template - a glimmer template compiled via ember-cli-htmlbars
* @param data - will be made available at `@data` in your template
*/
constructor(widget, renderInto, template, data) {
assert(
"`template` should be a template compiled via `ember-cli-htmlbars`, or a component",
template.name === "factory" || hasInternalComponentManager(template)
);
this.renderInto = renderInto;
if (widget) {
this.widget = widget;
}
this.template = template;
this.data = data;
}
init() {
if (this.renderInto instanceof Element) {
this.element = this.renderInto;
} else {
const [type, ...classNames] = this.renderInto.split(".");
this.element = document.createElement(type);
this.element.classList.add(...classNames);
this.element[INITIAL_CLASSES] = classNames;
}
this.connectComponent();
return this.element;
}
destroy() {
if (this._componentInfo) {
this.parentMountWidgetComponent.unmountChildComponent(
this._componentInfo
);
}
}
update(prev) {
if (
prev.template.__id !== this.template.__id ||
prev.renderInto !== this.renderInto
) {
// Totally different component, but the widget framework guessed it was the
// same widget. Destroy old component and re-init the new one.
prev.destroy();
return this.init();
}
this._componentInfo = prev._componentInfo;
if (prev.data !== this.data) {
this._componentInfo.data = this.data;
}
return null;
}
connectComponent() {
const { element, template } = this;
let component;
if (hasInternalComponentManager(template)) {
component = template;
} else {
component = templateOnly();
component.name = "Widgets/RenderGlimmer";
setComponentTemplate(template, component);
}
this._componentInfo = new ComponentInfo({
element,
component,
data: this.data,
setWrapperElementAttrs: (attrs) =>
this.updateElementAttrs(element, attrs),
});
this.parentMountWidgetComponent.mountChildComponent(this._componentInfo);
}
updateElementAttrs(element, attrs) {
for (let [key, value] of Object.entries(attrs)) {
if (key === "class") {
value = [element[INITIAL_CLASSES], value].filter(Boolean).join(" ");
}
if ([null, undefined].includes(value)) {
element.removeAttribute(key);
} else {
element.setAttribute(key, value);
}
}
}
get parentMountWidgetComponent() {
if (this._emberView) {
return this._emberView;
}
// Work up parent widgets until we find one with a _emberView
// attribute. `.parentWidget` is the normal way to work up the tree,
// but we use `attrs._postCookedWidget` to handle the special case
// of widgets rendered inside post-cooked.
let widget = this.widget;
while (widget) {
const component = widget._emberView;
if (component) {
return component;
}
widget = widget.parentWidget || widget.attrs._postCookedWidget;
}
}
}
RenderGlimmer.prototype.type = "Widget";
import { warnWidgetsDecommissioned } from "discourse/widgets/widget";
/**
* Define a widget shim which renders a Glimmer template. Designed for incrementally migrating
* a widget-based UI to Glimmer. Widget attrs will be made available to your template at `@data`.
* For more details, see documentation for the RenderGlimmer class.
* @param name - the widget's name (which can then be used in `.attach` elsewhere)
* @param tagName - a string describing a new wrapper element (e.g. `div.my-class`)
* @param template - a glimmer template compiled via ember-cli-htmlbars
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export function registerWidgetShim(name, tagName, template) {
if (
getOwnerWithFallback(this)?.lookup(`service:site-settings`)
?.deactivate_widgets_rendering
) {
warnWidgetsDeprecation(
`Widgets are deactivated. Your site may not work properly. Affected widget: ${name}.`
);
} else {
warnWidgetsDeprecation(
`Using \`registerWidgetShim\` is deprecated and will soon stop working. Affected widgetShim: ${name}.`
);
export default class RenderGlimmer {
constructor() {
warnWidgetsDecommissioned();
}
const RenderGlimmerShim = class MyClass extends RenderGlimmer {
constructor(attrs) {
super(null, tagName, template, attrs);
return this;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {}
get widget() {
return this.parentWidget;
}
/**
* @deprecated the widget rendering system was decommissioned
*/
destroy() {}
didRenderWidget() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
update() {}
willRerenderWidget() {}
};
/**
* @deprecated the widget rendering system was decommissioned
*/
connectComponent() {}
createWidgetFrom(RenderGlimmerShim, name, {});
/**
* @deprecated the widget rendering system was decommissioned
*/
updateElementAttrs() {}
/**
* @deprecated the widget rendering system was decommissioned
*/
get parentMountWidgetComponent() {}
}
class ComponentInfo {
@tracked data;
element;
component;
setWrapperElementAttrs;
constructor(params) {
Object.assign(this, params);
}
}
export { createWidgetFrom as registerWidgetShim } from "discourse/widgets/widget";
@@ -1,14 +1,8 @@
import { h } from "virtual-dom";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
export default createWidget("topic-post-visited-line", {
tagName: "div.small-action.topic-post-visited",
html(attrs) {
return h(
`div.topic-post-visited-line.post-${attrs.post_number}}`,
h("span.topic-post-visited-message", i18n("topics.new_messages_marker"))
);
},
});
export { DummyWidget as default } from "discourse/widgets/widget";
@@ -1,12 +0,0 @@
import { hbs } from "ember-cli-htmlbars";
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
registerWidgetShim(
"topic-status",
"span.topic-statuses",
hbs`<TopicStatus
@topic={{@data.topic}}
@disableActions={{@data.disableActions}}
@tagName=""
/>`
);
@@ -0,0 +1,14 @@
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
/**
* This is a shim used to prevent breaking imports from "virtual-dom"
*/
export {
warnWidgetsDecommissioned as create,
warnWidgetsDecommissioned as h,
} from "discourse/widgets/widget";
+123 -292
View File
@@ -1,325 +1,156 @@
import { schedule } from "@ember/runloop";
import { createPopper } from "@popperjs/core";
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
import { i18n } from "discourse-i18n";
/*
widget-dropdown
Usage
-----
{{attach
widget="widget-dropdown"
attrs=(hash
id=id
label=label
content=content
onChange=onChange
options=(hash)
)
}}
Mandatory attributes:
- id: must be unique in the application
- label or translatedLabel:
- label: an i18n key to be translated and displayed on the header
- translatedLabel: an already translated label to display on the header
- onChange: action called when a click happens on a row, content[rowIndex] will be passed as params
Optional attributes:
- class: adds css class to the dropdown
- content: list of items to display, if undefined or empty dropdown won't display
Example content:
```
[
{ id: 1, label: "foo.bar" },
"separator",
{ id: 2, translatedLabel: "FooBar" },
{ id: 3 label: "foo.baz", icon: "xmark" },
{ id: 4, html: "<b>foo</b>" }
]
```
- options: accepts a hash of optional attributes
- headerClass: adds css class to the dropdown header
- bodyClass: adds css class to the dropdown header
- caret: adds a caret to visually enforce this is a dropdown
- disabled: adds disabled css class and lock dropdown
*/
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export const WidgetDropdownHeaderClass = {
tagName: "button",
/**
* @deprecated the widget rendering system was decommissioned
*/
transform() {},
transform(attrs) {
return { label: this._buildLabel(attrs) };
},
/**
* @deprecated the widget rendering system was decommissioned
*/
buildAttributes() {},
buildAttributes(attrs) {
return { title: this._buildLabel(attrs) };
},
/**
* @deprecated the widget rendering system was decommissioned
*/
buildClasses() {},
buildClasses(attrs) {
let classes = ["widget-dropdown-header", "btn", "btn-default"];
if (attrs.class) {
classes = classes.concat(attrs.class.split(" "));
}
return classes.filter(Boolean).join(" ");
},
/**
* @deprecated the widget rendering system was decommissioned
*/
click() {},
click(event) {
event.preventDefault();
this.sendWidgetAction("_onTrigger");
},
template: hbs`
{{#if attrs.icon}}
{{d-icon attrs.icon}}
{{/if}}
<span class="label">
{{transformed.label}}
</span>
{{#if attrs.caret}}
{{d-icon "caret-down"}}
{{/if}}
`,
_buildLabel(attrs) {
return attrs.translatedLabel ? attrs.translatedLabel : i18n(attrs.label);
},
/**
* @deprecated the widget rendering system was decommissioned
*/
_buildLabel() {},
};
createWidget("widget-dropdown-header", WidgetDropdownHeaderClass);
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export const WidgetDropdownItemClass = {
tagName: "div",
/**
* @deprecated the widget rendering system was decommissioned
*/
transform() {},
transform(attrs) {
return {
content:
attrs.item === "separator"
? "<hr>"
: attrs.item.html
? attrs.item.html
: attrs.item.translatedLabel
? attrs.item.translatedLabel
: i18n(attrs.item.label),
};
},
/**
* @deprecated the widget rendering system was decommissioned
*/
buildAttributes() {},
buildAttributes(attrs) {
return {
"data-id": attrs.item.id,
tabindex: attrs.item === "separator" ? -1 : 0,
};
},
/**
* @deprecated the widget rendering system was decommissioned
*/
buildClasses() {},
buildClasses(attrs) {
const classes = [
"widget-dropdown-item",
attrs.item === "separator" ? "separator" : `item-${attrs.item.id}`,
];
classes.push(attrs.item.disabled ? "disabled" : "");
return classes.join(" ");
},
/**
* @deprecated the widget rendering system was decommissioned
*/
keyDown() {},
keyDown(event) {
if (event.key === "Enter") {
event.preventDefault();
this.sendWidgetAction("_onChange", this.attrs.item);
}
},
click(event) {
event.preventDefault();
this.sendWidgetAction("_onChange", this.attrs.item);
},
template: hbs`
{{#if attrs.item.icon}}
{{d-icon attrs.item.icon}}
{{/if}}
{{{transformed.content}}}
`,
/**
* @deprecated the widget rendering system was decommissioned
*/
click() {},
};
createWidget("widget-dropdown-item", WidgetDropdownItemClass);
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export const WidgetDropdownBodyClass = {
tagName: "div",
/**
* @deprecated the widget rendering system was decommissioned
*/
buildClasses() {},
buildClasses(attrs) {
return `widget-dropdown-body ${attrs.class || ""}`;
},
clickOutside() {
this.sendWidgetAction("hideBody");
},
template: hbs`
{{#each attrs.content as |item|}}
{{attach
widget="widget-dropdown-item"
attrs=(hash item=item)
}}
{{/each}}
`,
/**
* @deprecated the widget rendering system was decommissioned
*/
clickOutside() {},
};
createWidget("widget-dropdown-body", WidgetDropdownBodyClass);
/**
* This class is kept only for backward compatibility.
*
* @deprecated This class is part of the decommissioned widget system and should not be used anymore.
*/
export const WidgetDropdownClass = {
tagName: "div",
/**
* @deprecated the widget rendering system was decommissioned
*/
init() {},
init(attrs) {
if (!attrs) {
throw "A widget-dropdown expects attributes.";
}
/**
* @deprecated the widget rendering system was decommissioned
*/
buildKey() {},
if (!attrs.id) {
throw "A widget-dropdown expects a unique `id` attribute.";
}
/**
* @deprecated the widget rendering system was decommissioned
*/
buildAttributes() {},
if (!attrs.label && !attrs.translatedLabel) {
throw "A widget-dropdown expects at least a `label` or `translatedLabel`";
}
},
/**
* @deprecated the widget rendering system was decommissioned
*/
defaultState() {},
buildKey: (attrs) => {
return attrs.id;
},
/**
* @deprecated the widget rendering system was decommissioned
*/
buildClasses() {},
buildAttributes(attrs) {
return { id: attrs.id };
},
/**
* @deprecated the widget rendering system was decommissioned
*/
transform() {},
defaultState(attrs) {
return {
opened: false,
disabled: (attrs.options && attrs.options.disabled) || false,
};
},
/**
* @deprecated the widget rendering system was decommissioned
*/
hideBody() {},
buildClasses(attrs) {
const classes = ["widget-dropdown"];
classes.push(this.state.opened ? "opened" : "closed");
classes.push(this.state.disabled ? "disabled" : "");
return classes.join(" ") + " " + (attrs.class || "");
},
/**
* @deprecated the widget rendering system was decommissioned
*/
_onChange() {},
transform(attrs) {
return {
options: attrs.options || {},
isDropdownVisible: !this.state.disabled && this.state.opened,
};
},
/**
* @deprecated the widget rendering system was decommissioned
*/
destroy() {},
hideBody() {
this.state.opened = false;
},
/**
* @deprecated the widget rendering system was decommissioned
*/
willRerenderWidget() {},
_onChange(params) {
if (params.disabled) {
return;
}
this.state.opened = false;
/**
* @deprecated the widget rendering system was decommissioned
*/
didRenderWidget() {},
if (this.attrs.onChange) {
if (typeof this.attrs.onChange === "string") {
this.sendWidgetAction(this.attrs.onChange, params);
} else {
this.attrs.onChange(params);
}
}
},
destroy() {
if (this._popper) {
this._popper.destroy();
this._popper = null;
}
},
willRerenderWidget() {
this._popper && this._popper.destroy();
},
didRenderWidget() {
if (this.state.opened) {
schedule("afterRender", () => {
const dropdownHeader = document.querySelector(
`#${this.attrs.id} .widget-dropdown-header`
);
if (!dropdownHeader) {
return;
}
const dropdownBody = document.querySelector(
`#${this.attrs.id} .widget-dropdown-body`
);
if (!dropdownBody) {
return;
}
this._popper = createPopper(dropdownHeader, dropdownBody, {
strategy: "absolute",
placement: "bottom-start",
modifiers: [
{
name: "preventOverflow",
},
{
name: "offset",
options: {
offset: [0, 5],
},
},
],
});
});
}
},
_onTrigger() {
this.state.opened = !this.state.opened;
},
template: hbs`
{{#if attrs.content}}
{{attach
widget="widget-dropdown-header"
attrs=(hash
icon=attrs.icon
label=attrs.label
translatedLabel=attrs.translatedLabel
class=this.transformed.options.headerClass
caret=this.transformed.options.caret
)
}}
{{#if this.transformed.isDropdownVisible}}
{{attach
widget="widget-dropdown-body"
attrs=(hash
id=attrs.id
class=this.transformed.options.bodyClass
content=attrs.content
)
}}
{{/if}}
{{/if}}
`,
/**
* @deprecated the widget rendering system was decommissioned
*/
_onTrigger() {},
};
export default createWidget("widget-dropdown", WidgetDropdownClass);
export { DummyWidget as default } from "discourse/widgets/widget";
+46 -557
View File
@@ -1,36 +1,12 @@
import { get } from "@ember/object";
import { getOwner, setOwner } from "@ember/owner";
import { camelize } from "@ember/string";
import { Promise } from "rsvp";
import { h } from "virtual-dom";
import deprecated, { isDeprecationSilenced } from "discourse/lib/deprecated";
import { isProduction } from "discourse/lib/environment";
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import { deepMerge } from "discourse/lib/object";
/**
* IMPORTANT: The widget rendering system has been decommissioned.
*
* This file is maintained only to prevent breaking imports in existing third-party customizations.
* New code should not use this component or the widget system.
*/
import deprecated from "discourse/lib/deprecated";
import { consolePrefix } from "discourse/lib/source-identifier";
import DecoratorHelper from "discourse/widgets/decorator-helper";
import {
WidgetChangeHook,
WidgetClickHook,
WidgetClickOutsideHook,
WidgetDoubleClickHook,
WidgetDragHook,
WidgetInputHook,
WidgetKeyDownHook,
WidgetKeyUpHook,
WidgetMouseDownHook,
WidgetMouseDownOutsideHook,
WidgetMouseMoveHook,
WidgetMouseOutHook,
WidgetMouseOverHook,
WidgetMouseUpHook,
WidgetPointerOutHook,
WidgetPointerOverHook,
WidgetTouchEndHook,
WidgetTouchMoveHook,
WidgetTouchStartHook,
} from "discourse/widgets/hooks";
import { i18n } from "discourse-i18n";
export const WIDGET_DEPRECATION_OPTIONS = {
since: "v3.5.0.beta8-dev",
@@ -44,533 +20,46 @@ export const POST_STREAM_DEPRECATION_OPTIONS = {
url: "https://meta.discourse.org/t/372063/1",
};
export function warnWidgetsDeprecation(message, dontSkipCore = false) {
if (
(dontSkipCore || consolePrefix()) &&
!isDeprecationSilenced(POST_STREAM_DEPRECATION_OPTIONS.id)
) {
deprecated(message, WIDGET_DEPRECATION_OPTIONS);
export const WIDGET_DECOMMISSION_OPTIONS = {
since: "v3.6.0.beta3-latest",
id: "discourse.widgets-decommissioned",
url: "https://meta.discourse.org/t/375332/1",
};
function warnWidgetsDecommissioned() {
deprecated(
`"The widget rendering system has been decommissioned and all related components and APIs are no-longer operational.`,
WIDGET_DECOMMISSION_OPTIONS
);
}
class DummyWidget {
constructor() {
// skip if the constructor is run in core code
// some fake widgets are still instantiated to prevent breaking imports
if (consolePrefix()) {
warnWidgetsDecommissioned();
}
}
}
const _registry = {};
export function queryRegistry(name) {
return _registry[name];
function dummyCreateWidgetFrom() {
return DummyWidget;
}
export function deleteFromRegistry(name) {
return delete _registry[name];
}
const _decorators = {};
export function decorateWidget(decorateIdentifier, cb) {
const widgetName = decorateIdentifier.split(":")[0];
if (!_registry[widgetName]) {
// eslint-disable-next-line no-console
console.error(
consolePrefix(),
`decorateWidget: Could not find widget '${widgetName}' in registry`
);
}
_decorators[decorateIdentifier] ??= [];
_decorators[decorateIdentifier].push(cb);
}
export function traverseCustomWidgets(tree, callback) {
if (!tree) {
return;
}
if (tree.__type === "CustomWidget") {
callback(tree);
}
(tree.children || (tree.vnode ? tree.vnode.children : [])).forEach((node) => {
traverseCustomWidgets(node, callback);
});
}
export function applyDecorators(widget, type, attrs, state) {
const decorators = _decorators[`${widget.name}:${type}`] || [];
if (decorators.length) {
const helper = new DecoratorHelper(widget, attrs, state);
return decorators.map((d) => d(helper));
}
return [];
}
export function resetDecorators() {
Object.keys(_decorators).forEach((key) => delete _decorators[key]);
}
const _customSettings = {};
export function changeSetting(widgetName, settingName, newValue) {
_customSettings[widgetName] = _customSettings[widgetName] || {};
_customSettings[widgetName][settingName] = newValue;
}
export function createWidgetFrom(base, name, opts) {
const result = class CustomWidget extends base {};
// todo this shouldn't been needed anymore once we don't transpile for IE anymore
// see: https://discuss.emberjs.com/t/constructor-name-behaves-differently-in-dev-and-prod-builds-for-models-defined-with-the-es6-class-syntax/15572/6
// once done, we can just check on constructor.name
result.prototype.__type = "CustomWidget";
if (name) {
_registry[name] = result;
}
opts.name = name;
if (opts.template) {
opts.html = opts.template;
}
Object.keys(opts).forEach((k) => (result.prototype[k] = opts[k]));
return result;
}
export function createWidget(name, opts) {
if (
getOwnerWithFallback(this)?.lookup(`service:site-settings`)
?.deactivate_widgets_rendering
) {
warnWidgetsDeprecation(
`Widgets are deactivated. Your site may not work properly. Affected widget: ${name}.`
);
} else {
warnWidgetsDeprecation(
`Using \`api.createWidget\` is deprecated and will soon stop working. Use Glimmer components instead. Affected widget: ${name}.`
);
}
return createWidgetFrom(Widget, name, opts);
}
export function reopenWidget(name, opts) {
let existing = _registry[name];
if (!existing) {
// eslint-disable-next-line no-console
console.error(
consolePrefix(),
`reopenWidget: Could not find widget ${name} in registry`
);
return;
}
if (opts.template) {
opts.html = opts.template;
}
Object.keys(opts).forEach((k) => {
let old = existing.prototype[k];
if (old instanceof Function) {
// Add support for `this._super()` to reopened widgets if the prototype exists in the
// base object
existing.prototype[k] = function (...args) {
let ctx = Object.create(this);
ctx._super = (...superArgs) => old.apply(this, superArgs);
return opts[k].apply(ctx, args);
};
} else {
existing.prototype[k] = opts[k];
}
});
return existing;
}
export default class Widget {
constructor(attrs, register, opts) {
opts = opts || {};
this.attrs = attrs || {};
this.mergeState = opts.state;
this.model = opts.model;
this.register = register;
this.dirtyKeys = opts.dirtyKeys;
register.deprecateContainer(this);
setOwner(this, getOwner(register));
this.key = this.buildKey ? this.buildKey(attrs) : null;
this.site = register.lookup("service:site");
this.siteSettings = register.lookup("service:site-settings");
this.currentUser = register.lookup("service:current-user");
this.capabilities = register.lookup("service:capabilities");
this.store = register.lookup("service:store");
this.appEvents = register.lookup("service:app-events");
this.keyValueStore = register.lookup("service:key-value-store");
// We can inject services into widgets by passing a `services` parameter on creation
(this.services || []).forEach((s) => {
this[camelize(s)] = register.lookup(`service:${s}`);
});
this.init(this.attrs);
if (this.name) {
const custom = _customSettings[this.name];
if (custom) {
Object.keys(custom).forEach((k) => (this.settings[k] = custom[k]));
}
}
}
init() {}
transform() {
return {};
}
defaultState() {
return {};
}
destroy() {}
get(propertyPath) {
return get(this, propertyPath);
}
render(prev) {
const { dirtyKeys } = this;
if (prev && prev.key && prev.key === this.key) {
this.state = prev.state;
} else {
// Helps debug widgets
this.state = this.defaultState(this.attrs, this.state);
if (!isProduction()) {
if (typeof this.state !== "object") {
throw new Error(`defaultState must return an object`);
} else if (Object.keys(this.state).length > 0 && !this.key) {
throw new Error(`you need a key when using state in ${this.name}`);
}
}
}
// Sometimes we pass state down from the parent
if (this.mergeState) {
this.state = deepMerge(this.state, this.mergeState);
}
if (prev) {
const dirtyOpts = dirtyKeys.optionsFor(prev.key);
if (prev.shadowTree) {
this.shadowTree = true;
if (!dirtyOpts.dirty && !dirtyKeys.allDirty()) {
return prev.vnode;
}
}
if (prev.key) {
dirtyKeys.renderedKey(prev.key);
}
const refreshAction = dirtyOpts.onRefresh;
if (refreshAction) {
this.sendWidgetAction(refreshAction, dirtyOpts.refreshArg);
}
}
return this.draw(h, this.attrs, this.state);
}
_findAncestorWithProperty(property) {
let widget = this;
while (widget) {
const value = widget[property];
if (value) {
return widget;
}
widget = widget.parentWidget;
}
}
_findView() {
const widget = this._findAncestorWithProperty("_emberView");
if (widget) {
return widget._emberView;
}
}
lookupWidgetClass(widgetName) {
let WidgetClass = _registry[widgetName];
if (WidgetClass) {
return WidgetClass;
}
if (!this.register) {
// eslint-disable-next-line no-console
console.error("couldn't find register");
return null;
}
WidgetClass = this.register.lookupFactory(`widget:${widgetName}`);
if (WidgetClass && WidgetClass.class) {
return WidgetClass.class;
}
return null;
}
attach(widgetName, attrs, opts, otherOpts = {}) {
let WidgetClass = this.lookupWidgetClass(widgetName);
if (!WidgetClass && otherOpts.fallbackWidgetName) {
WidgetClass = this.lookupWidgetClass(otherOpts.fallbackWidgetName);
}
if (WidgetClass) {
const result = new WidgetClass(attrs, this.register, opts);
result.parentWidget = this;
result.dirtyKeys = this.dirtyKeys;
if (otherOpts.tagName) {
result.tagName = otherOpts.tagName;
}
return result;
} else {
throw new Error(
`Couldn't find ${widgetName} or fallback ${otherOpts.fallbackWidgetName}`
);
}
}
didRenderWidget() {}
willRerenderWidget() {}
scheduleRerender() {
let widget = this;
while (widget) {
if (widget.shadowTree) {
this.dirtyKeys.keyDirty(widget.key);
}
const rerenderable = widget._rerenderable;
if (rerenderable) {
return rerenderable.queueRerender();
}
widget = widget.parentWidget;
}
}
_sendComponentAction(name, param) {
let promise;
const view = this._findView();
if (view) {
let method;
if (typeof name === "function") {
method = name;
} else {
method = view.get(name);
if (!method) {
// eslint-disable-next-line no-console
console.warn(`${name} not found`);
return;
}
}
if (typeof method === "string") {
view[method](param);
promise = Promise.resolve();
} else {
const target = view.get("target") || view;
promise = method.call(target, param);
if (!promise || !promise.then) {
promise = Promise.resolve(promise);
}
}
}
return this.rerenderResult(() => promise);
}
findAncestorModel() {
const modelWidget = this._findAncestorWithProperty("model");
if (modelWidget) {
return modelWidget.model;
}
}
rerenderResult(fn) {
this.scheduleRerender();
const result = fn();
// re-render after any promises complete, too!
if (result && result.then) {
return result.then(() => this.scheduleRerender());
}
return result;
}
sendWidgetEvent(name, attrs) {
const methodName = `${name}Event`;
return this.rerenderResult(() => {
const widget = this._findAncestorWithProperty(methodName);
if (widget) {
return widget[methodName](attrs);
}
});
}
callWidgetFunction(name, param) {
const widget = this._findAncestorWithProperty(name);
if (widget) {
return widget[name].call(widget, param);
}
}
sendWidgetAction(name, param) {
return this.rerenderResult(() => {
const widget = this._findAncestorWithProperty(name);
if (widget) {
return widget[name].call(widget, param);
}
return this._sendComponentAction(name, param || this.findAncestorModel());
});
}
html() {}
draw(builder, attrs, state) {
const properties = {};
if (this.buildClasses) {
let classes = this.buildClasses(attrs, state) || [];
if (!Array.isArray(classes)) {
classes = [classes];
}
const customClasses = applyDecorators(this, "classNames", attrs, state);
if (customClasses && customClasses.length) {
classes = classes.concat(customClasses);
}
if (classes.length) {
properties.className = classes.join(" ");
}
}
if (this.buildId) {
properties.id = this.buildId(attrs);
}
if (this.buildAttributes) {
properties.attributes = this.buildAttributes(attrs);
}
if (this.keyUp) {
properties["widget-key-up"] = new WidgetKeyUpHook(this);
}
if (this.keyDown) {
properties["widget-key-down"] = new WidgetKeyDownHook(this);
}
if (this.clickOutside) {
properties["widget-click-outside"] = new WidgetClickOutsideHook(this);
}
if (this.click) {
properties["widget-click"] = new WidgetClickHook(this);
}
if (this.doubleClick) {
properties["widget-double-click"] = new WidgetDoubleClickHook(this);
}
if (this.mouseDownOutside) {
properties["widget-mouse-down-outside"] = new WidgetMouseDownOutsideHook(
this
);
}
if (this.drag) {
properties["widget-drag"] = new WidgetDragHook(this);
}
if (this.input) {
properties["widget-input"] = new WidgetInputHook(this);
}
if (this.change) {
properties["widget-change"] = new WidgetChangeHook(this);
}
if (this.mouseDown) {
properties["widget-mouse-down"] = new WidgetMouseDownHook(this);
}
if (this.mouseUp) {
properties["widget-mouse-up"] = new WidgetMouseUpHook(this);
}
if (this.mouseMove) {
properties["widget-mouse-move"] = new WidgetMouseMoveHook(this);
}
if (this.mouseOver) {
properties["widget-mouse-over"] = new WidgetMouseOverHook(this);
}
if (this.pointerOver) {
properties["widget-pointer-over"] = new WidgetPointerOverHook(this);
}
if (this.pointerOut) {
properties["widget-pointer-out"] = new WidgetPointerOutHook(this);
}
if (this.mouseOut) {
properties["widget-mouse-out"] = new WidgetMouseOutHook(this);
}
if (this.touchStart) {
properties["widget-touch-start"] = new WidgetTouchStartHook(this);
}
if (this.touchEnd) {
properties["widget-touch-end"] = new WidgetTouchEndHook(this);
}
if (this.touchMove) {
properties["widget-touch-move"] = new WidgetTouchMoveHook(this);
}
const attributes = properties["attributes"] || {};
properties.attributes = attributes;
if (this.title) {
if (typeof this.title === "function") {
attributes.title = this.title(attrs, state);
} else {
attributes.title = i18n(this.title);
}
}
this.transformed = this.transform(this.attrs, this.state);
let contents = this.html(attrs, state);
if (this.name) {
const beforeContents =
applyDecorators(this, "before", attrs, state) || [];
const afterContents = applyDecorators(this, "after", attrs, state) || [];
contents = beforeContents.concat(contents).concat(afterContents);
}
return h(this.tagName || "div", properties, contents);
}
}
Widget.prototype.type = "Thunk";
export {
warnWidgetsDecommissioned,
warnWidgetsDecommissioned as warnWidgetsDeprecation,
warnWidgetsDecommissioned as queryRegistry,
warnWidgetsDecommissioned as deleteFromRegistry,
warnWidgetsDecommissioned as decorateWidget,
warnWidgetsDecommissioned as traverseCustomWidgets,
warnWidgetsDecommissioned as applyDecorators,
warnWidgetsDecommissioned as resetDecorators,
warnWidgetsDecommissioned as changeSetting,
dummyCreateWidgetFrom as createWidgetFrom,
dummyCreateWidgetFrom as createWidget,
warnWidgetsDecommissioned as reopenWidget,
DummyWidget,
DummyWidget as default,
};
-2
View File
@@ -47,7 +47,6 @@
"chartjs-plugin-datalabels": "2.2.0",
"decorator-transforms": "^2.3.0",
"diff": "^8.0.2",
"discourse-widget-hbs": "workspace:1.0.0",
"ember-curry-component": "^0.3.1",
"ember-resolver": "^13.1.1",
"ember-route-template": "^1.0.3",
@@ -157,7 +156,6 @@
"testem": "^3.16.0",
"typescript": "^5.9.3",
"util": "^0.12.5",
"virtual-dom": "^2.1.1",
"webpack": "5.99.9",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3",
File diff suppressed because it is too large Load Diff
@@ -3,57 +3,45 @@ import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { i18n } from "discourse-i18n";
["enabled", "disabled"].forEach((postStreamMode) => {
acceptance(
`Composer - Draft saving (glimmer_post_stream_mode = ${postStreamMode})`,
function (needs) {
needs.settings({
glimmer_post_stream_mode: postStreamMode,
});
needs.user();
acceptance(`Composer - Draft saving`, function (needs) {
needs.user();
const draftThatWillBeSaved = "This_will_be_saved_successfully";
const draftThatWillBeSaved = "This_will_be_saved_successfully";
needs.pretender((server, helper) => {
server.post("/drafts.json", (request) => {
const success = request.requestBody.includes(draftThatWillBeSaved);
return success
? helper.response({ success: true })
: helper.response(500, {});
});
});
needs.pretender((server, helper) => {
server.post("/drafts.json", (request) => {
const success = request.requestBody.includes(draftThatWillBeSaved);
return success
? helper.response({ success: true })
: helper.response(500, {});
});
});
test("Shows a warning if a draft wasn't saved", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(
".topic-post[data-post-number='1'] button.show-more-actions"
);
await click(".topic-post[data-post-number='1'] button.edit");
test("Shows a warning if a draft wasn't saved", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".topic-post[data-post-number='1'] button.show-more-actions");
await click(".topic-post[data-post-number='1'] button.edit");
await fillIn(".d-editor-input", draftThatWillBeSaved);
await fillIn(".d-editor-input", draftThatWillBeSaved);
assert
.dom("div#draft-status span")
.doesNotExist("the draft was saved, there's no warning");
assert
.dom("div#draft-status span")
.doesNotExist("the draft was saved, there's no warning");
await fillIn(".d-editor-input", "This won't be saved because of error");
assert
.dom("div#draft-status span")
.hasText(
i18n("composer.drafts_offline"),
"the draft wasn't saved, a warning is rendered"
);
assert
.dom("div#draft-status svg.d-icon-triangle-exclamation")
.exists("an exclamation icon is rendered");
await fillIn(".d-editor-input", "This won't be saved because of error");
assert
.dom("div#draft-status span")
.hasText(
i18n("composer.drafts_offline"),
"the draft wasn't saved, a warning is rendered"
);
assert
.dom("div#draft-status svg.d-icon-triangle-exclamation")
.exists("an exclamation icon is rendered");
await fillIn(".d-editor-input", draftThatWillBeSaved);
assert
.dom("div#draft-status span")
.doesNotExist(
"the draft was saved again, the warning has disappeared"
);
});
}
);
await fillIn(".d-editor-input", draftThatWillBeSaved);
assert
.dom("div#draft-status span")
.doesNotExist("the draft was saved again, the warning has disappeared");
});
});
@@ -2,46 +2,35 @@ import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
["enabled", "disabled"].forEach((postStreamMode) => {
acceptance(
`Composer - Edit conflict (glimmer_post_stream_mode = ${postStreamMode})`,
function (needs) {
needs.settings({
glimmer_post_stream_mode: postStreamMode,
});
acceptance(`Composer - Edit conflict`, function (needs) {
needs.user();
needs.user();
let lastBody;
needs.pretender((server, helper) => {
server.post("/drafts.json", (request) => {
lastBody = request.requestBody;
return helper.response({ success: true });
});
});
let lastBody;
needs.pretender((server, helper) => {
server.post("/drafts.json", (request) => {
lastBody = request.requestBody;
return helper.response({ success: true });
});
});
test("Should not send 'original_text' when posting a new reply", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".topic-post[data-post-number='1'] button.reply");
await fillIn(
".d-editor-input",
"hello world hello world hello world hello world hello world"
);
assert.false(lastBody.includes("original_text"));
});
test("Should not send 'original_text' when posting a new reply", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".topic-post[data-post-number='1'] button.reply");
await fillIn(
".d-editor-input",
"hello world hello world hello world hello world hello world"
);
assert.false(lastBody.includes("original_text"));
});
test("Should send 'original_text' when editing a reply", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(
".topic-post[data-post-number='1'] button.show-more-actions"
);
await click(".topic-post[data-post-number='1'] button.edit");
await fillIn(
".d-editor-input",
"hello world hello world hello world hello world hello world"
);
assert.true(lastBody.includes("original_text"));
});
}
);
test("Should send 'original_text' when editing a reply", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".topic-post[data-post-number='1'] button.show-more-actions");
await click(".topic-post[data-post-number='1'] button.edit");
await fillIn(
".d-editor-input",
"hello world hello world hello world hello world hello world"
);
assert.true(lastBody.includes("original_text"));
});
});

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