Merge branch 'main' into feature_pm_strikethrough

This commit is contained in:
Renato Atilio
2025-02-04 15:24:00 -03:00
committed by GitHub
361 changed files with 7503 additions and 1644 deletions

View File

@@ -68,6 +68,8 @@ updates:
- dependency-name: "moment-timezone"
- dependency-name: "@discourse/moment-timezone-names-translations"
- dependency-name: "squoosh"
- dependency-name: "@glint/*" # Using unstable version - don't auto-upgrade to stable
- dependency-name: "typescript" # Very sensitive to glint/volar version
groups:
babel:
patterns:

View File

@@ -88,4 +88,4 @@ jobs:
- name: Glint
if: ${{ !cancelled() }}
run: pnpm glint -p jsconfig.json
run: pnpm glint -p jsconfig.json --noEmit

View File

@@ -99,7 +99,7 @@ gem "sidekiq"
gem "mini_scheduler"
gem "execjs", require: false
gem "mini_racer", "0.17.pre12"
gem "mini_racer", "0.17.pre13"
gem "highline", require: false

View File

@@ -203,7 +203,7 @@ GEM
json-schema (5.1.1)
addressable (~> 2.8)
bigdecimal (~> 3.1)
json_schemer (2.3.0)
json_schemer (2.4.0)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
@@ -247,7 +247,7 @@ GEM
mini_racer (>= 0.6.3)
method_source (1.1.0)
mini_mime (1.1.5)
mini_racer (0.17.0.pre12)
mini_racer (0.17.0.pre13)
libv8-node (~> 22.7.0.4)
mini_scheduler (0.18.0)
sidekiq (>= 6.5, < 8.0)
@@ -458,21 +458,21 @@ GEM
rspec-core (>= 2.14)
rtlcss (0.2.1)
mini_racer (>= 0.6.3)
rubocop (1.71.0)
rubocop (1.71.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-discourse (3.9.2)
rubocop-discourse (3.9.3)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
@@ -671,7 +671,7 @@ DEPENDENCIES
message_bus
messageformat-wrapper
mini_mime
mini_racer (= 0.17.pre12)
mini_racer (= 0.17.pre13)
mini_scheduler
mini_sql
mini_suffix

View File

@@ -2,23 +2,38 @@
<img src="images/discourse-readme-logo.png" width="300px">
</a>
Discourse is the online home for your community. We offer a 100% open source community platform to those who want complete control over how and where their site is run.
The online home for your community.
Our platform has been battle-tested for over a decade and continues to evolve to meet users needs for a powerful community platform. Discourse allows you to create discussion topics and connect using real-time chat, as well as access an ever-growing number of official and community themes. In addition, we offer a wide variety of plugins for features ranging from chatbots powered by [Discourse AI](https://meta.discourse.org/t/discourse-ai/259214) to functionalities like SQL analysis using the [Data Explorer](https://meta.discourse.org/t/discourse-data-explorer/32566) plugin.
![readme](https://github.com/user-attachments/assets/db764ef2-5cc2-4873-b11d-4a2052e1993d)
To learn more, visit [**discourse.org**](https://www.discourse.org) and join our support community at [meta.discourse.org](https://meta.discourse.org).
## Screenshots
> You can self-host Discourse on your own infrastructure. But if you'd rather skip the setup, maintenance, and server management, we offer official Discourse hosting.
>
> 👉 Learn more about [Discourse hosting](https://discourse.org/pricing)
<a href="https://blog.discourse.org/2023/08/discourse-3-1-is-here/"><img alt="Discourse 3.1" src="https://github-production-user-asset-6210df.s3.amazonaws.com/5862206/261215898-ae95f963-5ab4-4509-b87a-f9f6e9a109bf.png" width="720px"></a>
Discourse is a 100% open-source community platform for those who want complete control over how and where their site is run.
<a href="https://forum.homeexchange.com"><img alt="HomeExchange" src="https://github.com/user-attachments/assets/42fbbd25-502f-4047-a348-c93fb99d7986" width="720px"></a>
Our platform has been battle-tested for over a decade and continues to evolve to meet users needs for a powerful community platform.
<a href="https://twittercommunity.com/"><img alt="X Community" src="https://github.com/discourse/discourse/assets/2790986/ebb63eee-1927-4060-ada1-cf1bc774084c.png" width="720px"></a>
**With Discourse, you can:**
<img width="414" alt="Mobile Preview" src="https://github.com/user-attachments/assets/6e209258-258a-48f9-8a31-1b7d9eee4b77">
* 💬 **Create discussion topics** to foster meaningful conversations.
* ⚡️ **Connect in real-time** with built-in chat.
* 🎨 **Customize your experience** with an ever-growing selection of official and community themes.
* 🤖 **Enhance your community** with plugins, from chatbots powered by [Discourse AI](https://meta.discourse.org/t/discourse-ai/259214) to advanced tools like SQL analysis with the [Data Explorer](https://meta.discourse.org/t/discourse-data-explorer/32566) plugin.
To learn more, visit [discourse.org](https://www.discourse.org/) and join our support community at [meta.discourse.org](https://meta.discourse.org/).
Here are just a few of the incredible communities using Discourse:
![discourse-communities](https://github.com/user-attachments/assets/a79b5d56-7748-4f6d-8a2d-daa950366fcc)
👉 [Discover more communities using Discourse](https://discover.discourse.org/)
Browse [lots more notable Discourse instances](https://www.discourse.org/customers).
## Development
@@ -115,3 +130,8 @@ To guide our ongoing effort to build accessible software we follow the [W3Cs
## Dedication
Discourse is built with [love, Internet style.](https://www.youtube.com/watch?v=Xe1TZaElTAs)
For over a decade, our [amazing community](https://meta.discourse.org/) has helped shape Discourse into what it is today. Your support, feedback, and contributions have been invaluable in making Discourse a powerful and versatile platform.
Were deeply grateful for every feature request, bug report, and discussion that has driven Discourse forward. Thank you for being a part of this journey—we couldnt have done it without you!

View File

@@ -1,6 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
@@ -28,6 +29,11 @@ export default class AdminAreaSettings extends Component {
return !this.loading && this.settings.length > 0;
}
@action
async reloadSettings() {
await this.#loadSettings();
}
@bind
async #loadSettings() {
this.loading = true;
@@ -69,6 +75,7 @@ export default class AdminAreaSettings extends Component {
<div
class="content-body admin-config-area__settings admin-detail pull-left"
{{didUpdate this.reloadSettings @plugin}}
>
{{#if this.showSettings}}
<AdminFilteredSiteSettings

View File

@@ -13,7 +13,6 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import ApiKeyUrlsModal from "admin/components/modal/api-key-urls";
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";
import DTooltip from "float-kit/components/d-tooltip";
export default class AdminConfigAreasApiKeysNew extends Component {
@service router;
@@ -239,7 +238,6 @@ export default class AdminConfigAreasApiKeysNew extends Component {
<table class="scopes-table grid">
<thead>
<tr>
<td></td>
<td></td>
<td>{{i18n "admin.api.scopes.allowed_urls"}}</td>
<td>{{i18n
@@ -265,28 +263,18 @@ export default class AdminConfigAreasApiKeysNew extends Component {
<topicsCollection.Field
@name="enabled"
@title={{collectionData.key}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</topicsCollection.Field>
</td>
<td>
<div
class="scope-name"
>{{collectionData.name}}</div>
<DTooltip
@icon="circle-question"
@content={{i18n
@tooltip={{i18n
(concat
"admin.api.scopes.descriptions."
scopeName
"."
collectionData.key
)
class="scope-tooltip"
}}
/>
as |field|
>
<field.Checkbox />
</topicsCollection.Field>
</td>
<td>
<DButton

View File

@@ -257,13 +257,11 @@ export default class AdminConfigAreasWebhookForm extends Component {
</field.Custom>
</form.Field>
<span>
<PluginOutlet
@name="web-hook-fields"
@connectorTagName="div"
@outletArgs={{hash model=this.webhook}}
/>
</span>
<PluginOutlet
@name="web-hook-fields"
@connectorTagName="div"
@outletArgs={{hash model=this.webhook}}
/>
<form.Field
@name="verify_certificate"

View File

@@ -6,6 +6,7 @@ import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import DButton from "discourse/components/d-button";
import autoFocus from "discourse/modifiers/auto-focus";
import { i18n } from "discourse-i18n";
export default class AdminSiteSettingsFilterControls extends Component {
@@ -71,6 +72,7 @@ export default class AdminSiteSettingsFilterControls extends Component {
{{/if}}
<input
{{on "input" this.onChangeFilterInput}}
{{autoFocus}}
id="setting-filter"
class="no-blur admin-site-settings-filter-controls__input"
placeholder={{i18n "type_to_filter"}}

View File

@@ -9,12 +9,14 @@ import SiteSettingFilter from "discourse/lib/site-setting-filter";
export default class AdminSiteSettingsController extends Controller {
@service router;
@service currentUser;
@alias("model") allSiteSettings;
filter = "";
visibleSiteSettings = null;
siteSettingFilter = null;
showSettingCategorySidebar = !this.currentUser.use_admin_sidebar;
filterContentNow(filterData, category) {
this.siteSettingFilter ??= new SiteSettingFilter(this.allSiteSettings);
@@ -23,12 +25,16 @@ export default class AdminSiteSettingsController extends Controller {
return;
}
if (isEmpty(filterData.filter) && !filterData.onlyOverridden) {
this.set("visibleSiteSettings", this.allSiteSettings);
if (this.categoryNameKey === "all_results") {
this.router.transitionTo("adminSiteSettings");
// We want to land on All by default if admin sidebar is shown, in this
// case we are hiding the inner site setting category sidebar.
if (this.showSettingCategorySidebar) {
if (isEmpty(filterData.filter) && !filterData.onlyOverridden) {
this.set("visibleSiteSettings", this.allSiteSettings);
if (this.categoryNameKey === "all_results") {
this.router.transitionTo("adminSiteSettings");
}
return;
}
return;
}
this.set("filter", filterData.filter);

View File

@@ -1,5 +1,5 @@
import { cached, tracked } from "@glimmer/tracking";
import { capitalize, dasherize } from "@ember/string";
import { dasherize } from "@ember/string";
import { snakeCaseToCamelCase } from "discourse/lib/case-converter";
import I18n, { i18n } from "discourse-i18n";
@@ -50,29 +50,7 @@ export default class AdminPlugin {
@cached
get nameTitleized() {
// The category name is better in a lot of cases, as it's a human-inputted
// translation, and we can handle things like SAML instead of showing them
// as Saml from discourse-saml. We can fall back to the programmatic version
// though if needed.
let name;
if (this.translatedCategoryName) {
name = this.translatedCategoryName;
} else {
name = this.name
.split(/[-_]/)
.map((word) => {
return capitalize(word);
})
.join(" ");
}
// Cuts down on repetition.
const discoursePrefix = "Discourse ";
if (name.startsWith(discoursePrefix)) {
name = name.slice(discoursePrefix.length);
}
return name;
return this.translatedCategoryName || this.humanizedName;
}
@cached

View File

@@ -7,12 +7,15 @@ import DiscourseRoute from "discourse/routes/discourse";
export default class AdminSiteSettingsIndexRoute extends DiscourseRoute {
@service router;
@service currentUser;
beforeModel() {
this.router.replaceWith(
"adminSiteSettingsCategory",
this.controllerFor("adminSiteSettings").get("visibleSiteSettings")[0]
.nameKey
);
if (!this.currentUser.use_admin_sidebar) {
this.router.replaceWith(
"adminSiteSettingsCategory",
this.controllerFor("adminSiteSettings").get("visibleSiteSettings")[0]
.nameKey
);
}
}
}

View File

@@ -7,10 +7,17 @@
/>
{{/each}}
{{#if this.category.hasMore}}
<p class="warning">{{i18n
"admin.site_settings.more_site_setting_results"
count=this.category.maxResults
}}</p>
{{#if this.currentUser.use_admin_sidebar}}
<p class="warning">{{i18n
"admin.site_settings.more_site_setting_results_no_category_sidebar"
count=this.category.maxResults
}}</p>
{{else}}
<p class="warning">{{i18n
"admin.site_settings.more_site_setting_results"
count=this.category.maxResults
}}</p>
{{/if}}
{{/if}}
</section>
{{else}}

View File

@@ -19,32 +19,39 @@
@onToggleMenu={{this.toggleMenu}}
/>
<div class="admin-nav admin-site-settings-category-nav pull-left">
<ul class="nav nav-stacked">
{{#each this.visibleSiteSettings as |category|}}
<li
class={{concat-class
"admin-site-settings-category-nav__item"
category.nameKey
}}
>
<LinkTo
@route="adminSiteSettingsCategory"
@model={{category.nameKey}}
class={{category.nameKey}}
title={{category.name}}
{{#if this.showSettingCategorySidebar}}
<div class="admin-nav admin-site-settings-category-nav pull-left">
<ul class="nav nav-stacked">
{{#each this.visibleSiteSettings as |category|}}
<li
class={{concat-class
"admin-site-settings-category-nav__item"
category.nameKey
}}
>
{{category.name}}
{{#if category.count}}
<span class="count">({{category.count}})</span>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
<LinkTo
@route="adminSiteSettingsCategory"
@model={{category.nameKey}}
class={{category.nameKey}}
title={{category.name}}
>
{{category.name}}
{{#if category.count}}
<span class="count">({{category.count}})</span>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="admin-detail pull-left mobile-closed">
<div
class={{concat-class
"admin-detail pull-left mobile-closed"
(unless this.showSettingCategorySidebar "-without-inner-sidebar")
}}
>
{{outlet}}
</div>

View File

@@ -18,7 +18,7 @@
"@ember/string": "^4.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.2.0"
"ember-template-imports": "^4.3.0"
},
"devDependencies": {
"@ember/optional-features": "^2.2.0",
@@ -44,7 +44,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -28,6 +28,6 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
}
}

View File

@@ -10,6 +10,6 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
}
}

View File

@@ -12,7 +12,7 @@
"ember-auto-import": "^2.10.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.2.0",
"ember-template-imports": "^4.3.0",
"truth-helpers": "workspace:1.0.0"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
}
}

View File

@@ -11,7 +11,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"exports": {
"./raw-handlebars-compiler": "./raw-handlebars-compiler.js"

View File

@@ -24,7 +24,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "octane"

View File

@@ -36,7 +36,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "octane"

View File

@@ -14,7 +14,7 @@
"discourse-widget-hbs": "workspace:1.0.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.2.0",
"ember-template-imports": "^4.3.0",
"ember-this-fallback": "^0.4.0"
},
"devDependencies": {
@@ -25,7 +25,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -42,7 +42,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -1,6 +1,6 @@
<ComposerBody
@composer={{this.composer.model}}
@showPreview={{this.composer.showPreview}}
@showPreview={{this.composer.isPreviewVisible}}
@openIfDraft={{this.composer.openIfDraft}}
@typed={{this.composer.typed}}
@cancelled={{this.composer.cancelled}}
@@ -8,7 +8,7 @@
>
<div class="grippie"></div>
{{#if this.composer.visible}}
{{html-class (if this.composer.showPreview "composer-has-preview")}}
{{html-class (if this.composer.isPreviewVisible "composer-has-preview")}}
<ComposerMessages
@composer={{this.composer.model}}
@messageCount={{this.composer.messageCount}}
@@ -137,7 +137,7 @@
<div
class="title-and-category
{{if this.composer.showPreview 'with-preview'}}"
{{if this.composer.isPreviewVisible 'with-preview'}}"
>
<ComposerTitle
@composer={{this.composer.model}}
@@ -205,7 +205,7 @@
@connectorTagName="div"
@outletArgs={{hash
model=this.composer.model
showPreview=this.composer.showPreview
showPreview=this.composer.isPreviewVisible
}}
/>
</span>
@@ -239,25 +239,21 @@
/>
{{#if this.site.mobileView}}
<a
href
{{on "click" this.composer.cancel}}
title={{i18n "cancel"}}
class="cancel"
>
{{#if this.composer.canEdit}}
{{d-icon "xmark"}}
{{else}}
{{d-icon "trash-can"}}
{{/if}}
</a>
<DButton
@action={{this.composer.cancel}}
class="cancel btn-transparent"
@icon={{if this.composer.canEdit "xmark" "trash-can"}}
@preventFocus={{true}}
@title="close"
/>
{{else}}
<a
href
{{on "click" this.composer.cancel}}
class="cancel"
role="button"
>{{i18n "close"}}</a>
<DButton
@action={{this.composer.cancel}}
class="cancel btn-transparent"
@preventFocus={{true}}
@title="close"
@label="close"
/>
{{/if}}
{{#if this.site.mobileView}}
@@ -299,17 +295,19 @@
</a>
{{/if}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{#if this.composer.allowPreview}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "composer.show_preview"}}
>
{{d-icon "desktop"}}
</a>
{{/if}}
{{#if this.composer.showPreview}}
{{#if this.composer.isPreviewVisible}}
<DButton
@action={{this.composer.togglePreview}}
@title="composer.hide_preview"
@@ -370,14 +368,14 @@
{{/if}}
</div>
{{#if this.site.desktopView}}
{{#if (and this.composer.allowPreview this.site.desktopView)}}
<DButton
@action={{this.composer.togglePreview}}
@translatedTitle={{this.composer.toggleText}}
@icon="angles-left"
class={{concat-class
"btn-transparent btn-mini-toggle toggle-preview"
(unless this.composer.showPreview "active")
(unless this.composer.isPreviewVisible "active")
}}
/>
{{/if}}

View File

@@ -30,8 +30,7 @@
@previewUpdated={{action "previewUpdated"}}
@markdownOptions={{this.markdownOptions}}
@extraButtons={{action "extraButtons"}}
@importQuote={{this.composer.importQuote}}
@processPreview={{this.composer.showPreview}}
@processPreview={{this.composer.isPreviewVisible}}
@validation={{this.validation}}
@loading={{this.composer.loading}}
@forcePreview={{this.forcePreview}}

View File

@@ -194,17 +194,29 @@ export default class ComposerEditor extends Component {
this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
}
/**
* Sets up the editor with the given text manipulation instance
*
* @param {TextManipulation} textManipulation The text manipulation instance
* @returns {(() => void)} destructor function
*/
@bind
setupEditor(textManipulation) {
this.textManipulation = textManipulation;
this.uppyComposerUpload.textManipulation = textManipulation;
this.uppyComposerUpload.placeholderHandler = textManipulation.placeholder;
const input = this.element.querySelector(".d-editor-input");
input.addEventListener("scroll", this._throttledSyncEditorAndPreviewScroll);
// Focus on the body unless we have a title
if (!this.get("composer.model.canEditTitle")) {
this.composer.set("allowPreview", this.textManipulation.allowPreview);
if (
// Focus on the editor unless we have a title
!this.get("composer.model.canEditTitle") ||
// Or focus is in the body (e.g. when the editor is destroyed)
document.activeElement.tagName === "BODY"
) {
this.textManipulation.putCursorAtEnd();
}
@@ -859,15 +871,6 @@ export default class ComposerEditor extends Component {
@action
extraButtons(toolbar) {
toolbar.addButton({
id: "quote",
group: "fontStyles",
icon: "far-comment",
sendAction: this.composer.importQuote,
title: "composer.quote_post_title",
unshift: true,
});
if (
this.composer.allowUpload &&
this.composer.uploadIcon &&

View File

@@ -110,7 +110,7 @@ export default class TextareaEditor extends Component {
@input={{@change}}
@focusIn={{@focusIn}}
@focusOut={{@focusOut}}
class="d-editor-input"
class={{@class}}
@id={{@id}}
{{this.registerTextarea}}
/>

View File

@@ -0,0 +1,47 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
export default class ComposerToggleSwitch extends Component {
@action
mouseDown(event) {
if (this.args.preventFocus) {
event.preventDefault();
}
}
<template>
{{! template-lint-disable no-redundant-role }}
<button
class={{concatClass
"composer-toggle-switch"
(if @state "--rte" "--markdown")
}}
type="button"
role="switch"
aria-checked={{if @state "true" "false"}}
{{! template-lint-disable no-pointer-down-event-binding }}
{{on "mousedown" this.mouseDown}}
...attributes
>
<span class="composer-toggle-switch__slider">
<span
class={{concatClass
"composer-toggle-switch__left-icon"
(unless @state "--active")
}}
aria-hidden="true"
>{{icon "fab-markdown"}}</span>
<span
class={{concatClass
"composer-toggle-switch__right-icon"
(if @state "--active")
}}
aria-hidden="true"
>{{icon "a"}}</span>
</span>
</button>
</template>
}

View File

@@ -8,6 +8,14 @@
{{if this.isEditorFocused 'in-focus'}}"
>
<div class="d-editor-button-bar" role="toolbar">
{{#if this.siteSettings.rich_editor}}
<Composer::ToggleSwitch
@preventFocus={{true}}
@state={{this.isRichEditorEnabled}}
{{on "click" this.toggleRichEditor}}
/>
{{/if}}
{{#each this.toolbar.groups as |group|}}
{{#each group.buttons as |b|}}
{{#if (b.condition this)}}
@@ -40,15 +48,18 @@
<ConditionalLoadingSpinner @condition={{this.loading}} />
<this.editorComponent
@class="d-editor-input"
@onSetup={{this.setupEditor}}
@markdownOptions={{this.markdownOptions}}
@keymap={{this.keymap}}
@value={{this.value}}
@placeholder={{this.placeholderTranslated}}
@disabled={{this.disabled}}
@change={{this.change}}
@change={{this.onChange}}
@focusIn={{this.handleFocusIn}}
@focusOut={{this.handleFocusOut}}
@categoryId={{@categoryId}}
@topicId={{@topicId}}
@id={{this.textAreaId}}
/>
<PopupInputTip @validation={{this.validation}} />

View File

@@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import Component from "@ember/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
@@ -26,6 +27,7 @@ import { wantsNewWindow } from "discourse/lib/intercept-click";
import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import loadRichEditor from "discourse/lib/load-rich-editor";
import { findRawTemplate } from "discourse/lib/raw-templates";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import userSearch from "discourse/lib/user-search";
@@ -59,8 +61,9 @@ export default class DEditor extends Component {
@service modal;
@service menu;
editorComponent = TextareaEditor;
textManipulation;
@tracked editorComponent;
/** @type {TextManipulation} */
@tracked textManipulation;
ready = false;
lastSel = null;
@@ -74,10 +77,19 @@ export default class DEditor extends Component {
},
};
init() {
async init() {
super.init(...arguments);
this.register = getRegister(this);
if (
this.siteSettings.rich_editor &&
this.keyValueStore.get("d-editor-prefers-rich-editor") === "true"
) {
this.editorComponent = await loadRichEditor();
} else {
this.editorComponent = TextareaEditor;
}
}
@discourseComputed("placeholder")
@@ -630,9 +642,15 @@ export default class DEditor extends Component {
this.set("isEditorFocused", false);
}
/**
* Sets up the editor with the given text manipulation instance
*
* @param {TextManipulation} textManipulation The text manipulation instance
* @returns {(() => void)} destructor function
*/
@action
setupEditor(textManipulation) {
this.set("textManipulation", textManipulation);
this.textManipulation = textManipulation;
const destroyEvents = this.setupEvents();
@@ -657,6 +675,28 @@ export default class DEditor extends Component {
};
}
@action
async toggleRichEditor() {
this.editorComponent = this.isRichEditorEnabled
? TextareaEditor
: await loadRichEditor();
this.keyValueStore.set({
key: "d-editor-prefers-rich-editor",
value: this.isRichEditorEnabled,
});
}
@action
onChange(event) {
this.set("value", event?.target?.value);
this.change?.(event);
}
get isRichEditorEnabled() {
return this.editorComponent !== TextareaEditor;
}
setupEvents() {
const textManipulation = this.textManipulation;

View File

@@ -86,6 +86,8 @@ export default class PostList extends Component {
@additionalItemClasses={{@additionalItemClasses}}
@titleAriaLabel={{@titleAriaLabel}}
@showUserInfo={{@showUserInfo}}
@resumeDraft={{@resumeDraft}}
@removeDraft={{@removeDraft}}
>
<:abovePostItemHeader>
{{yield post to="abovePostItemHeader"}}

View File

@@ -1,8 +1,13 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { fn, hash } from "@ember/helper";
import { or } from "truth-helpers";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicStatus from "discourse/components/topic-status";
import avatar from "discourse/helpers/avatar";
import categoryLink from "discourse/helpers/category-link";
import icon from "discourse/helpers/d-icon";
import formatDate from "discourse/helpers/format-date";
import getURL from "discourse/lib/get-url";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { i18n } from "discourse-i18n";
@@ -28,6 +33,10 @@ export default class PostListItemDetails extends Component {
: this.args.post.title;
}
get draftTitle() {
return this.args.post.title ?? this.args.post.data.title;
}
get titleAriaLabel() {
if (this.args.titleAriaLabel) {
return this.args.titleAriaLabel;
@@ -58,14 +67,42 @@ export default class PostListItemDetails extends Component {
href={{getURL this.url}}
aria-label={{this.titleAriaLabel}}
>{{this.topicTitle}}</a>
{{else if @isDraft}}
<DButton
@action={{fn @resumeDraft @post}}
class="btn-transparent draft-title"
>
{{or this.draftTitle (i18n "drafts.dropdown.untitled")}}
</DButton>
{{else}}
{{this.topicTitle}}
{{/if}}
</span>
</div>
<div class="category stream-post-category">
{{categoryLink @post.category}}
<div class="post-list-item__metadata">
{{#if @post.category}}
<span class="category stream-post-category">
{{categoryLink @post.category}}
</span>
{{/if}}
<span class="time">
{{formatDate @post.created_at leaveAgo="true"}}
</span>
{{#if @post.deleted_by}}
<span class="delete-info">
{{icon "trash-can"}}
{{avatar
@post.deleted_by
imageSize="tiny"
extraClasses="actor"
ignoreTitle="true"
}}
{{formatDate @item.deleted_at leaveAgo="true"}}
</span>
{{/if}}
</div>
{{#if this.showUserInfo}}

View File

@@ -1,12 +1,13 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import ExpandPost from "discourse/components/expand-post";
import PostListItemDetails from "discourse/components/post-list/item/details";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import formatDate from "discourse/helpers/format-date";
import { userPath } from "discourse/lib/url";
export default class PostListItem extends Component {
@@ -53,6 +54,22 @@ export default class PostListItem extends Component {
: this.args.post.id;
}
get isDraft() {
return this.args.post.constructor.name === "UserDraft";
}
get draftIcon() {
const key = this.args.post.draft_key;
if (key.startsWith("new_private_message")) {
return "envelope";
} else if (key.startsWith("new_topic")) {
return "layer-group";
} else {
return "reply";
}
}
<template>
<div
class="post-list-item
@@ -66,20 +83,26 @@ export default class PostListItem extends Component {
{{yield to="abovePostItemHeader"}}
<div class="post-list-item__header info">
<a
href={{userPath this.user.username}}
data-user-card={{this.user.username}}
class="avatar-link"
>
<div class="avatar-wrapper">
{{avatar
this.user
imageSize="large"
extraClasses="actor"
ignoreTitle="true"
}}
{{#if this.isDraft}}
<div class="draft-icon">
{{icon this.draftIcon class="icon"}}
</div>
</a>
{{else}}
<a
href={{userPath this.user.username}}
data-user-card={{this.user.username}}
class="avatar-link"
>
<div class="avatar-wrapper">
{{avatar
this.user
imageSize="large"
extraClasses="actor"
ignoreTitle="true"
}}
</div>
</a>
{{/if}}
<PostListItemDetails
@post={{@post}}
@@ -88,33 +111,31 @@ export default class PostListItem extends Component {
@urlPath={{@urlPath}}
@user={{this.user}}
@showUserInfo={{@showUserInfo}}
@isDraft={{this.isDraft}}
@resumeDraft={{@resumeDraft}}
/>
{{#if @post.draftType}}
<span class="draft-type">{{@post.draftType}}</span>
{{else}}
{{#unless @post.draftType}}
<ExpandPost @item={{@post}} />
{{/unless}}
{{#if @post.editableDraft}}
<div class="user-stream-item-draft-actions">
<DButton
@action={{fn @resumeDraft @post}}
@icon="pencil"
@title="drafts.resume"
class="btn-default resume-draft"
/>
<DButton
@action={{fn @removeDraft @post}}
@icon="trash-can"
@title="drafts.remove"
class="btn-danger remove-draft"
/>
</div>
{{/if}}
<div class="post-list-item__metadata">
<span class="time">
{{formatDate @post.created_at leaveAgo="true"}}
</span>
{{#if @post.deleted_by}}
<span class="delete-info">
{{icon "trash-can"}}
{{avatar
@post.deleted_by
imageSize="tiny"
extraClasses="actor"
ignoreTitle="true"
}}
{{formatDate @item.deleted_at leaveAgo="true"}}
</span>
{{/if}}
</div>
{{yield to="belowPostItemMetadata"}}
</div>

View File

@@ -65,7 +65,7 @@ export default class PostMenuLikeButton extends Component {
"toggle-like"
"btn-icon"
(if this.isAnimated "heart-animation")
(if @post.liked "has-like fade-out" "like")
(if @post.liked "has-like" "like")
}}
...attributes
data-post-id={{@post.id}}

View File

@@ -58,7 +58,7 @@ export default class Item extends Component {
let expandPinned;
if (
!this.args.topic.pinned ||
(this.site.mobileView && !this.siteSettings.show_pinned_excerpt_mobile) ||
(this.useMobileLayout && !this.siteSettings.show_pinned_excerpt_mobile) ||
(this.site.desktopView && !this.siteSettings.show_pinned_excerpt_desktop)
) {
expandPinned = false;
@@ -71,7 +71,7 @@ export default class Item extends Component {
return applyValueTransformer(
"topic-list-item-expand-pinned",
expandPinned,
{ topic: this.args.topic, mobileView: this.site.mobileView }
{ topic: this.args.topic, mobileView: this.useMobileLayout }
);
}

View File

@@ -8,7 +8,10 @@ import Header from "discourse/components/topic-list/header";
import Item from "discourse/components/topic-list/item";
import concatClass from "discourse/helpers/concat-class";
import DAG from "discourse/lib/dag";
import { applyValueTransformer } from "discourse/lib/transformer";
import {
applyMutableValueTransformer,
applyValueTransformer,
} from "discourse/lib/transformer";
import { i18n } from "discourse-i18n";
import HeaderActivityCell from "./header/activity-cell";
import HeaderBulkSelectCell from "./header/bulk-select-cell";
@@ -98,7 +101,7 @@ export default class TopicList extends Component {
},
};
return applyValueTransformer(
return applyMutableValueTransformer(
"topic-list-columns",
defaultColumns,
context

View File

@@ -16,9 +16,9 @@
<TopicStatus @topic={{@item}} @disableActions={{true}} />
<span class="title">
{{#if @item.postUrl}}
<a href={{@item.postUrl}}>{{html-safe @item.title}}</a>
<a href={{@item.postUrl}}>{{replace-emoji @item.title}}</a>
{{else}}
{{html-safe @item.title}}
{{replace-emoji @item.title}}
{{/if}}
</span>
</div>

View File

@@ -1,13 +1,12 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import $ from "jquery";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import PostActionDescription from "discourse/components/post-action-description";
import PostList from "discourse/components/post-list";
@@ -164,6 +163,8 @@ export default class UserStreamComponent extends Component {
@titlePath="titleHtml"
@additionalItemClasses="user-stream-item"
@showUserInfo={{false}}
@resumeDraft={{this.resumeDraft}}
@removeDraft={{this.removeDraft}}
class={{concatClass "user-stream" this.filterClassName}}
{{this.eventListeners @stream}}
>
@@ -216,23 +217,6 @@ export default class UserStreamComponent extends Component {
{{/each}}
</div>
{{/each}}
{{#if post.editableDraft}}
<div class="user-stream-item-draft-actions">
<DButton
@action={{fn this.resumeDraft post}}
@icon="pencil"
@label="drafts.resume"
class="btn-default resume-draft"
/>
<DButton
@action={{fn this.removeDraft post}}
@icon="trash-can"
@title="drafts.remove"
class="btn-danger remove-draft"
/>
</div>
{{/if}}
</:abovePostItemExcerpt>
<:belowPostItem as |post|>

View File

@@ -15,17 +15,16 @@ import discourseDebounce from "discourse/lib/debounce";
import discourseComputed, { bind } from "discourse/lib/decorators";
import NameValidationHelper from "discourse/lib/name-validation-helper";
import { userPath } from "discourse/lib/url";
import UsernameValidationHelper from "discourse/lib/username-validation-helper";
import { emailValid } from "discourse/lib/utilities";
import PasswordValidation from "discourse/mixins/password-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import UsernameValidation from "discourse/mixins/username-validation";
import { findAll } from "discourse/models/login-method";
import User from "discourse/models/user";
import { i18n } from "discourse-i18n";
export default class SignupPageController extends Controller.extend(
PasswordValidation,
UsernameValidation,
UserFieldsValidation
) {
@service site;
@@ -44,6 +43,7 @@ export default class SignupPageController extends Controller.extend(
passwordValidationVisible = false;
emailValidationVisible = false;
nameValidationHelper = new NameValidationHelper(this);
usernameValidationHelper = new UsernameValidationHelper(this);
@notEmpty("authOptions") hasAuthOptions;
@setting("enable_local_logins") canCreateLocal;
@@ -59,6 +59,11 @@ export default class SignupPageController extends Controller.extend(
this.fetchConfirmationValue();
}
@dependentKeyCompat
get usernameValidation() {
return this.usernameValidationHelper.usernameValidation;
}
get nameTitle() {
return this.nameValidationHelper.nameTitle;
}
@@ -356,7 +361,11 @@ export default class SignupPageController extends Controller.extend(
// If email is valid and username has not been entered yet,
// or email and username were filled automatically by 3rd party auth,
// then look for a registered username that matches the email.
discourseDebounce(this, this.fetchExistingUsername, 500);
discourseDebounce(
this,
this.usernameValidationHelper.fetchExistingUsername,
500
);
}
}

View File

@@ -1,31 +1,28 @@
import Component from "@glimmer/component";
import { concat, fn } from "@ember/helper";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { eq } from "truth-helpers";
import FKLabel from "discourse/form-kit/components/fk/label";
import FKMeta from "discourse/form-kit/components/fk/meta";
import FKOptional from "discourse/form-kit/components/fk/optional";
import FKText from "discourse/form-kit/components/fk/text";
import FKTooltip from "discourse/form-kit/components/fk/tooltip";
import concatClass from "discourse/helpers/concat-class";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
export default class FKControlWrapper extends Component {
get controlType() {
if (this.args.component.controlType === "input") {
return this.args.component.controlType + "-" + (this.args.type || "text");
return this.args.field.type + "-" + (this.args.type || "text");
}
return this.args.component.controlType;
return this.args.field.type;
}
get error() {
return (this.args.errors ?? {})[this.args.field.name];
}
get isComponentTooltip() {
return typeof this.args.field.tooltip === "object";
}
get titleFormat() {
return this.args.field.titleFormat || this.args.field.format;
}
@@ -52,42 +49,34 @@ export default class FKControlWrapper extends Component {
data-disabled={{@field.disabled}}
data-name={{@field.name}}
data-control-type={{this.controlType}}
{{didInsert (fn @registerField @field.name @field)}}
{{willDestroy (fn @unregisterField @field.name)}}
>
{{#if @field.showTitle}}
<FKLabel
class={{concatClass
"form-kit__container-title"
(if this.titleFormat (concat "--" this.titleFormat))
}}
@fieldId={{@field.id}}
>
<span>{{@field.title}}</span>
{{#unless (eq @field.type "checkbox")}}
{{#if @field.showTitle}}
<FKLabel
class={{concatClass
"form-kit__container-title"
(if this.titleFormat (concat "--" this.titleFormat))
}}
@fieldId={{@field.id}}
>
<span>{{@field.title}}</span>
{{#unless @field.required}}
<span class="form-kit__container-optional">({{i18n
"form_kit.optional"
}})</span>
{{/unless}}
<FKOptional @field={{@field}} />
<FKTooltip @field={{@field}} />
</FKLabel>
{{/if}}
{{#if @field.tooltip}}
{{#if this.isComponentTooltip}}
<@field.tooltip />
{{else}}
<DTooltip @icon="circle-question" @content={{@field.tooltip}} />
{{/if}}
{{/if}}
</FKLabel>
{{/if}}
{{#if @field.description}}
<FKText
class={{concatClass
"form-kit__container-description"
(if this.descriptionFormat (concat "--" this.descriptionFormat))
}}
>{{@field.description}}</FKText>
{{/if}}
{{#if @field.description}}
<FKText
class={{concatClass
"form-kit__container-description"
(if this.descriptionFormat (concat "--" this.descriptionFormat))
}}
>{{@field.description}}</FKText>
{{/if}}
{{/unless}}
<div
class={{concatClass
@@ -105,7 +94,9 @@ export default class FKControlWrapper extends Component {
@before={{@before}}
@after={{@after}}
@height={{@height}}
@preview={{@preview}}
@selection={{@selection}}
@includeNone={{@includeNone}}
id={{@field.id}}
name={{@field.name}}
aria-invalid={{if this.error "true"}}

View File

@@ -3,6 +3,8 @@ import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import FKLabel from "discourse/form-kit/components/fk/label";
import FKOptional from "discourse/form-kit/components/fk/optional";
import FKTooltip from "discourse/form-kit/components/fk/tooltip";
export default class FKControlCheckbox extends Component {
static controlType = "checkbox";
@@ -24,7 +26,9 @@ export default class FKControlCheckbox extends Component {
/>
<span class="form-kit__control-checkbox-content">
<span class="form-kit__control-checkbox-title">
{{@field.title}}
<span>{{@field.title}}</span>
<FKOptional @field={{@field}} />
<FKTooltip @field={{@field}} />
</span>
{{#if (has-block)}}
<span class="form-kit__control-checkbox-description">{{yield}}</span>

View File

@@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DEditor from "discourse/components/d-editor";
import concatClass from "discourse/helpers/concat-class";
import { escapeExpression } from "discourse/lib/utilities";
export default class FKControlComposer extends Component {
@@ -13,7 +14,7 @@ export default class FKControlComposer extends Component {
}
get style() {
if (this.args.height) {
if (!this.args.height) {
return;
}
@@ -25,7 +26,10 @@ export default class FKControlComposer extends Component {
@value={{readonly @field.value}}
@change={{this.handleInput}}
@disabled={{@field.disabled}}
class="form-kit__control-composer"
class={{concatClass
"form-kit__control-composer"
(if @preview "--preview")
}}
style={{this.style}}
@textAreaId={{@field.id}}
/>

View File

@@ -21,7 +21,9 @@ export default class FKControlSelect extends Component {
return true;
}
return !this.args.field.validation?.includes("required");
return (
this.args.includeNone ?? !this.args.field.validation?.includes("required")
);
}
<template>

View File

@@ -1,4 +1,5 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import ValidationParser from "discourse/form-kit/lib/validation-parser";
import Validator from "discourse/form-kit/lib/validator";
@@ -8,6 +9,12 @@ import uniqueId from "discourse/helpers/unique-id";
* Represents a field in a form with validation, registration, and field data management capabilities.
*/
export default class FKFieldData extends Component {
/**
* Type of the field.
* @type {string}
*/
@tracked type;
/**
* Unique identifier for the field.
* @type {string}
@@ -20,12 +27,6 @@ export default class FKFieldData extends Component {
*/
errorId = uniqueId();
/**
* Type of the field.
* @type {string}
*/
type;
/**
* Initializes the FKFieldData component.
* Validates the presence of required arguments and registers the field.
@@ -37,8 +38,6 @@ export default class FKFieldData extends Component {
if (!this.args.title?.length) {
throw new Error("@title is required on `<form.Field />`.");
}
this.args.registerField(this.name, this);
}
/**

View File

@@ -1,5 +1,8 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import curryComponent from "ember-curry-component";
import FKControlCheckbox from "discourse/form-kit/components/fk/control/checkbox";
import FKControlCode from "discourse/form-kit/components/fk/control/code";
import FKControlComposer from "discourse/form-kit/components/fk/control/composer";
@@ -40,6 +43,30 @@ export default class FKField extends Component {
}
}
@action
componentFor(component, field) {
const instance = this;
const baseArguments = {
get errors() {
return instance.args.errors;
},
unregisterField: instance.args.unregisterField,
registerField: instance.args.registerField,
component,
field,
};
if (!component.controlType) {
throw new Error(
`Static property \`controlType\` is required on component:\n\n ${component}`
);
}
field.type = component.controlType;
return curryComponent(FKControlWrapper, baseArguments, getOwner(this));
}
<template>
<FKFieldData
@name={{@name}}
@@ -66,104 +93,20 @@ export default class FKField extends Component {
<this.wrapper @size={{@size}}>
{{yield
(hash
Custom=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlCustom
field=field
)
Code=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlCode
field=field
)
Question=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlQuestion
field=field
)
Textarea=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlTextarea
field=field
)
Checkbox=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlCheckbox
field=field
)
Image=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlImage
field=field
)
Password=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlPassword
field=field
)
Composer=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlComposer
field=field
)
Icon=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlIcon
field=field
)
Toggle=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlToggle
field=field
)
Menu=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlMenu
field=field
)
Select=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlSelect
field=field
)
Input=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlInput
field=field
)
RadioGroup=(component
FKControlWrapper
unregisterField=@unregisterField
errors=@errors
component=FKControlRadioGroup
field=field
)
Custom=(this.componentFor FKControlCustom field)
Code=(this.componentFor FKControlCode field)
Question=(this.componentFor FKControlQuestion field)
Textarea=(this.componentFor FKControlTextarea field)
Checkbox=(this.componentFor FKControlCheckbox field)
Image=(this.componentFor FKControlImage field)
Password=(this.componentFor FKControlPassword field)
Composer=(this.componentFor FKControlComposer field)
Icon=(this.componentFor FKControlIcon field)
Toggle=(this.componentFor FKControlToggle field)
Menu=(this.componentFor FKControlMenu field)
Select=(this.componentFor FKControlSelect field)
Input=(this.componentFor FKControlInput field)
RadioGroup=(this.componentFor FKControlRadioGroup field)
errorId=field.errorId
id=field.id
name=field.name

View File

@@ -0,0 +1,11 @@
import { i18n } from "discourse-i18n";
const FKOptional = <template>
{{#unless @field.required}}
<span class="form-kit__container-optional">({{i18n
"form_kit.optional"
}})</span>
{{/unless}}
</template>;
export default FKOptional;

View File

@@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import DTooltip from "float-kit/components/d-tooltip";
export default class FKTooltip extends Component {
get isComponentTooltip() {
return typeof this.args.field.tooltip === "object";
}
<template>
{{#if @field.tooltip}}
{{#if this.isComponentTooltip}}
<@field.tooltip />
{{else}}
<DTooltip
class="form-kit__tooltip"
@icon="circle-question"
@content={{@field.tooltip}}
/>
{{/if}}
{{/if}}
</template>
}

View File

@@ -2,6 +2,7 @@ import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { iconHTML } from "discourse/lib/icon-library";
import discourseLater from "discourse/lib/later";
import { withPluginApi } from "discourse/lib/plugin-api";
import { sanitize } from "discourse/lib/text";
import { i18n } from "discourse-i18n";
export default {
@@ -15,10 +16,37 @@ export default {
parentDiv.style.cursor = "";
overlay.innerHTML = spinnerHTML;
const videoSrc = sanitizeUrl(parentDiv.dataset.videoSrc);
const origSrc = sanitizeUrl(parentDiv.dataset.origSrc);
const dataOrigSrcAttr =
origSrc !== null ? `data-orig-src="${origSrc}"` : "";
if (videoSrc === null) {
const existingNotice = wrapper.querySelector(".notice.error");
if (existingNotice) {
existingNotice.remove();
}
const notice = document.createElement("div");
notice.className = "notice error";
notice.innerHTML =
iconHTML("triangle-exclamation") + " " + i18n("invalid_video_url");
wrapper.appendChild(notice);
overlay.innerHTML = iconHTML("play");
parentDiv.style.cursor = "pointer";
parentDiv.addEventListener(
"click",
(e) => handleVideoPlaceholderClick(helper, e),
{ once: true }
);
return;
}
const videoHTML = `
<video width="100%" height="100%" preload="metadata" controls style="display:none">
<source src="${parentDiv.dataset.videoSrc}" ${parentDiv.dataset.origSrc}>
<a href="${parentDiv.dataset.videoSrc}">${parentDiv.dataset.videoSrc}</a>
<source src="${videoSrc}" ${dataOrigSrcAttr}>
<a href="${videoSrc}">${videoSrc}</a>
</video>`;
parentDiv.insertAdjacentHTML("beforeend", videoHTML);
parentDiv.classList.add("video-container");
@@ -108,6 +136,33 @@ export default {
});
}
function sanitizeUrl(url) {
try {
const parsedUrl = new URL(url, window.location.origin);
if (
["http:", "https:"].includes(parsedUrl.protocol) ||
url.startsWith("/")
) {
const sanitized = sanitize(url);
if (
sanitized &&
sanitized.trim() !== "" &&
!sanitized.includes("&gt;") &&
!sanitized.includes("&lt;")
) {
return sanitized;
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn("Invalid URL encountered:", url, e.message);
}
return null;
}
api.decorateCookedElement(applyVideoPlaceholder, {
onlyStream: true,
});

View File

@@ -114,6 +114,7 @@ export default function (options) {
const isInput = me[0].tagName === "INPUT" && !options.treatAsTextarea;
let inputSelectedItems = [];
/** @type {AutocompleteHandler} */
options.textHandler ??= new TextareaAutocompleteHandler(me[0]);
function handlePaste() {
@@ -249,14 +250,14 @@ export default function (options) {
options.textHandler.getCaretPosition();
}
options.textHandler.replaceTerm({
start: completeStart,
end: completeEnd,
term: (options.preserveKey ? options.key || "" : "") + term,
});
options.textHandler.replaceTerm(
completeStart,
completeEnd,
(options.preserveKey ? options.key || "" : "") + term
);
if (options && options.afterComplete) {
options.afterComplete(options.textHandler.value, event);
options.afterComplete(options.textHandler.getValue(), event);
}
}
}
@@ -481,7 +482,9 @@ export default function (options) {
if (
(term.length !== 0 && term.trim().length === 0) ||
// close unless the caret is at the end of a word, like #line|<-
options.textHandler.value[options.textHandler.getCaretPosition()]?.trim()
options.textHandler
.getValue()
[options.textHandler.getCaretPosition()]?.trim()
) {
closeAutocomplete();
return null;
@@ -549,11 +552,11 @@ export default function (options) {
}
let cp = options.textHandler.getCaretPosition();
const key = options.textHandler.value[cp - 1];
const key = options.textHandler.getValue()[cp - 1];
if (options.key) {
if (options.onKeyUp && key !== options.key) {
let match = options.onKeyUp(options.textHandler.value, cp);
let match = options.onKeyUp(options.textHandler.getValue(), cp);
if (match && (await checkTriggerRule())) {
completeStart = cp - match[0].length;
@@ -565,7 +568,7 @@ export default function (options) {
if (completeStart === null && cp > 0) {
if (key === options.key) {
let prevChar = options.textHandler.value.charAt(cp - 2);
let prevChar = options.textHandler.getValue().charAt(cp - 2);
if (
(!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar)) &&
(await checkTriggerRule())
@@ -575,11 +578,17 @@ export default function (options) {
}
}
} else if (completeStart !== null) {
let term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
updateAutoComplete(dataSource(term, options));
let term = options.textHandler
.getValue()
.substring(completeStart + (options.key ? 1 : 0), cp);
if (
!options.key ||
options.textHandler.getValue()[completeStart] === options.key
) {
updateAutoComplete(dataSource(term, options));
} else {
closeAutocomplete();
}
}
}
@@ -601,12 +610,12 @@ export default function (options) {
while (prevIsGood && caretPos >= 0) {
caretPos -= 1;
prev = options.textHandler.value[caretPos];
prev = options.textHandler.getValue()[caretPos];
stopFound = prev === options.key;
if (stopFound) {
prev = options.textHandler.value[caretPos - 1];
prev = options.textHandler.getValue()[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace });
if (
@@ -614,10 +623,9 @@ export default function (options) {
(prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) {
start = caretPos;
term = options.textHandler.value.substring(
caretPos + 1,
initialCaretPos
);
term = options.textHandler
.getValue()
.substring(caretPos + 1, initialCaretPos);
end = caretPos + term.length;
break;
}
@@ -648,7 +656,7 @@ export default function (options) {
inputSelectedItems.push("");
}
const value = options.textHandler.value;
const value = options.textHandler.getValue();
if (typeof inputSelectedItems[0] === "string" && value.length > 0) {
inputSelectedItems.pop();
inputSelectedItems.push(value);
@@ -694,7 +702,7 @@ export default function (options) {
// allow people to right arrow out of completion
if (
e.which === keys.rightArrow &&
options.textHandler.value[cp] === " "
options.textHandler.getValue()[cp] === " "
) {
closeAutocomplete();
return true;
@@ -770,10 +778,9 @@ export default function (options) {
return true;
}
term = options.textHandler.value.substring(
completeStart + (options.key ? 1 : 0),
cp
);
term = options.textHandler
.getValue()
.substring(completeStart + (options.key ? 1 : 0), cp);
if (completeStart === cp && term === options.key) {
closeAutocomplete();

View File

@@ -0,0 +1,107 @@
// @ts-check
/**
* @typedef PluginContext
* @property {string} placeholder
* @property {number} topicId
* @property {number} categoryId
* @property {import("discourse/models/session").default} session
*/
/**
* @typedef PluginParams
* @property {typeof import("discourse/static/prosemirror/lib/plugin-utils")} utils
* @property {typeof import('prosemirror-model')} pmModel
* @property {typeof import('prosemirror-view')} pmView
* @property {typeof import('prosemirror-state')} pmState
* @property {typeof import('prosemirror-history')} pmHistory
* @property {typeof import('prosemirror-transform')} pmTransform
* @property {typeof import('prosemirror-commands')} pmCommands
* @property {import('prosemirror-model').Schema} schema
* @property {() => PluginContext} getContext
*/
/** @typedef {import('prosemirror-state').PluginSpec} PluginSpec */
/** @typedef {((params: PluginParams) => PluginSpec)} RichPluginFn */
/** @typedef {PluginSpec | RichPluginFn} RichPlugin */
/**
* @typedef InputRuleObject
* @property {RegExp} match
* @property {string | ((state: import('prosemirror-state').EditorState, match: RegExpMatchArray, start: number, end: number) => import('prosemirror-state').Transaction | null)} handler
* @property {{ undoable?: boolean, inCode?: boolean | "only" }} [options]
*/
/**
* @typedef InputRuleParams
* @property {import('prosemirror-model').Schema} schema
* @property {Function} markInputRule
*/
/** @typedef {((params: InputRuleParams) => InputRuleObject) | InputRuleObject} RichInputRule */
// @ts-ignore we don't have type definitions for markdown-it
/** @typedef {import("markdown-it").Token} MarkdownItToken */
/** @typedef {(state: unknown, token: MarkdownItToken, tokenStream: MarkdownItToken[], index: number) => boolean | void} ParseFunction */
/** @typedef {import("prosemirror-markdown").ParseSpec | ParseFunction} RichParseSpec */
/**
* @typedef {(state: import("prosemirror-markdown").MarkdownSerializerState, node: import("prosemirror-model").Node, parent: import("prosemirror-model").Node, index: number) => void} SerializeNodeFn
*/
/** @typedef {Record<string, import('prosemirror-state').Command>} KeymapSpec */
/** @typedef {((params: PluginParams) => KeymapSpec)} RichKeymapFn */
/** @typedef {KeymapSpec | RichKeymapFn} RichKeymap */
/**
* @typedef {Object} RichEditorExtension
* @property {Record<string, import('prosemirror-model').NodeSpec>} [nodeSpec]
* Map containing Prosemirror node spec definitions, each key being the node name
* See https://prosemirror.net/docs/ref/#model.NodeSpec
* @property {Record<string, import('prosemirror-model').MarkSpec>} [markSpec]
* Map containing Prosemirror mark spec definitions, each key being the mark name
* See https://prosemirror.net/docs/ref/#model.MarkSpec
* @property {RichInputRule | Array<RichInputRule>} [inputRules]
* ProseMirror input rules. See https://prosemirror.net/docs/ref/#inputrules.InputRule
* can be a function returning an array or an array of input rules
* @property {Record<string, SerializeNodeFn>} [serializeNode]
* Node serialization definition
* @ts-ignore MarkSerializerSpec not currently exported
* @property {Record<string, import('prosemirror-markdown').MarkSerializerSpec>} [serializeMark]
* Mark serialization definition
* @property {Record<string, RichParseSpec>} [parse]
* Markdown-it token parse definition
* @property {RichPlugin | Array<RichPlugin>} [plugins]
* ProseMirror plugins
* @property {Record<string, import('prosemirror-view').NodeViewConstructor>} [nodeViews]
* ProseMirror node views
* @property {RichKeymap} [keymap]
* Additional keymap definitions
*/
/** @type {RichEditorExtension[]} */
const registeredExtensions = [];
/**
* Registers an extension for the rich editor
*
* EXPERIMENTAL: This API will change without warning
*
* @param {RichEditorExtension} extension
*/
export function registerRichEditorExtension(extension) {
registeredExtensions.push(extension);
}
export function resetRichEditorExtensions() {
registeredExtensions.length = 0;
}
/**
* Get all extensions registered for the rich editor
*
* @returns {RichEditorExtension[]}
*/
export function getExtensions() {
return registeredExtensions;
}

View File

@@ -0,0 +1,305 @@
// @ts-check
/**
* Interface for text manipulation with an underlying editor implementation.
*
* @interface TextManipulation
*/
export const TextManipulation = {};
/**
* Whether the editor allows a preview being shown
* @name TextManipulation#allowPreview
* @type {boolean}
* @readonly
*/
/**
* Focuses the editor
*
* @method
* @name TextManipulation#focus
* @returns {void}
*/
/**
* Blurs and focuses the editor
*
* @method
* @name TextManipulation#blurAndFocus
* @returns {void}
*/
/**
* Indents/un-indents the current selection
*
* @method
* @name TextManipulation#indentSelection
* @param {string} direction The direction to indent in. Either "right" or "left"
* @returns {void}
*/
/**
* Configures an Autocomplete for the editor
*
* @method
* @name TextManipulation#autocomplete
* @param {unknown} options The options for the jQuery autocomplete
* @returns {void}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name TextManipulation#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the current selection
*
* @method
* @name TextManipulation#getSelected
* @param {unknown} trimLeading
* @returns {unknown}
*/
/**
* Selects the text from the given range
*
* @method
* @name TextManipulation#selectText
* @param {number} from
* @param {number} to
* @param {unknown} [options]
* @returns {void}
*/
/**
* Applies the given head/tail to the selected text
*
* @method
* @name TextManipulation#applySurround
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Applies the list format to the selected text
*
* @method
* @name TextManipulation#applyList
* @param {string} selected The selected text
* @param {string} head The text to be inserted before the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
*/
/**
* Formats the current selection as code
*
* @method
* @name TextManipulation#formatCode
* @returns {void}
*/
/**
* Adds text
*
* @method
* @name TextManipulation#addText
* @param {string} selected The selected text
* @param {string} text The text to be inserted
*/
/**
* Toggles the text (LTR/RTL) direction
*
* @method
* @name TextManipulation#toggleDirection
* @returns {void}
*/
/**
* Replaces text
*
* @method
* @name TextManipulation#replaceText
* @param {string} oldValue The old value
* @param {string} newValue The new value
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Handles the paste event
*
* @method
* @name TextManipulation#paste
* @param {ClipboardEvent} event The paste event
* @returns {void}
*/
/**
* Inserts the block
*
* @method
* @name TextManipulation#insertBlock
* @param {string} block The block to be inserted
* @returns {void}
*/
/**
* Inserts text
*
* @method
* @name TextManipulation#insertText
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Applies the head/tail to the selected text
*
* @method
* @name TextManipulation#applySurroundSelection
* @param {string} head The text to be inserted before the selection
* @param {string} tail The text to be inserted after the selection
* @param {string} exampleKey The key of the example
* @param {unknown} [opts]
* @returns {void}
*/
/**
* Puts cursor at the end of the editor
*
* @method
* @name TextManipulation#putCursorAtEnd
* @returns {void}
*/
/**
* The placeholder handler instance
*
* @name TextManipulation#placeholder
* @type {PlaceholderHandler}
* @readonly
*/
/** @typedef {import("@uppy/utils/lib/UppyFile").MinimalRequiredUppyFile<any,any>} UppyFile */
/**
* Interface for handling placeholders on upload events
*
* @interface PlaceholderHandler
*/
export const PlaceholderHandler = {};
/**
* Inserts a file
*
* @method
* @name PlaceholderHandler#insert
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Success event for file upload
*
* @method
* @name PlaceholderHandler#success
* @param {UppyFile} file The uploaded file
* @param {string} markdown The markdown for the uploaded file
* @returns {void}
*/
/**
* Cancels all uploads
*
* @method
* @name PlaceholderHandler#cancelAll
* @returns {void}
*/
/**
* Cancels one uploaded file
*
* @method
* @name PlaceholderHandler#cancel
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress event
*
* @method
* @name PlaceholderHandler#progress
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Progress complete event
*
* @method
* @name PlaceholderHandler#progressComplete
* @param {UppyFile} file The uploaded file
* @returns {void}
*/
/**
* Interface for the Autocomplete handler
*
* @interface AutocompleteHandler
*/
export const AutocompleteHandler = {};
/**
* Replaces the range with the given text
*
* @method
* @name AutocompleteHandler#replaceTerm
* @param {number} start The start of the range
* @param {number} end The end of the range
* @param {string} text The text to be inserted
* @returns {void}
*/
/**
* Gets the caret position
*
* @method
* @name AutocompleteHandler#getCaretPosition
* @returns {number}
*/
/**
* Checks if the current selection is in a code block
*
* @method
* @name AutocompleteHandler#inCodeBlock
* @returns {Promise<boolean>}
*/
/**
* Gets the caret coordinates
*
* @method
* @name AutocompleteHandler#getCaretCoords
* @param {number} caretPositon The caret position to get the coords for
* @returns {{ top: number, left: number }}
*/
/**
* Gets the current value for the autocomplete
*
* @method
* @name AutocompleteHandler#getValue
* @returns {string}
*/

View File

@@ -48,6 +48,7 @@ export default function interceptClick(e) {
target.classList.contains("ember-view")) ||
target.classList.contains("lightbox") ||
href.startsWith("mailto:") ||
target.closest('[contenteditable="true"]') ||
(href.match(/^http[s]?:\/\//i) &&
!href.match(new RegExp("^https?:\\/\\/" + window.location.hostname, "i")))
) {

View File

@@ -0,0 +1,5 @@
export default async function loadRichEditor() {
return (
await import("discourse/static/prosemirror/components/prosemirror-editor")
).default;
}

View File

@@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "2.0.1";
export const PLUGIN_API_VERSION = "2.1.0";
import $ from "jquery";
import { h } from "virtual-dom";
@@ -66,6 +66,7 @@ import classPrepend, {
withPrependsRolledBack,
} 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 from "discourse/lib/deprecated";
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
import { downloadCalendar } from "discourse/lib/download-calendar";
@@ -3389,6 +3390,17 @@ class PluginApi {
registerReportModeComponent(mode, componentClass);
}
/**
* Registers an extension for the rich editor
*
* EXPERIMENTAL: This API will change without warning
*
* @param {RichEditorExtension} extension
*/
registerRichEditorExtension(extension) {
registerRichEditorExtension(extension);
}
#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)) {
@@ -3466,7 +3478,14 @@ function getPluginApi(version) {
* @param {object} [opts] - Optional additional options to pass to the callback function.
* @returns {*} The result of the `callback` function, if executed
*/
export function withPluginApi(version, apiCodeCallback, opts) {
export function withPluginApi(...args) {
let version, apiCodeCallback, opts;
if (typeof args[0] === "function") {
[version, apiCodeCallback, opts] = ["0", ...args];
} else {
[version, apiCodeCallback, opts] = args;
}
opts = opts || {};
const api = getPluginApi(version);

View File

@@ -60,7 +60,9 @@ class SidebarAdminSectionLink extends BaseCustomSidebarSectionLink {
get text() {
return this.adminSidebarNavLink.label
? i18n(this.adminSidebarNavLink.label)
? i18n(this.adminSidebarNavLink.label, {
translatedFallback: this.adminSidebarNavLink.text,
})
: this.adminSidebarNavLink.text;
}
@@ -316,6 +318,7 @@ function pluginAdminRouteLinks(router) {
? [plugin.admin_route.location]
: [],
label: plugin.admin_route.label,
text: plugin.humanized_name,
icon: "gear",
};
});

View File

@@ -14,7 +14,7 @@ import {
import SectionLink from "discourse/lib/sidebar/section-link";
import AdminSectionLink from "discourse/lib/sidebar/user/community-section/admin-section-link";
import InviteSectionLink from "discourse/lib/sidebar/user/community-section/invite-section-link";
import MyDraftsSectionLink from "discourse/lib/sidebar/user/community-section/my-drafts-section-link";
import MyPostsSectionLink from "discourse/lib/sidebar/user/community-section/my-posts-section-link";
import ReviewSectionLink from "discourse/lib/sidebar/user/community-section/review-section-link";
const SPECIAL_LINKS_MAP = {
@@ -22,7 +22,7 @@ const SPECIAL_LINKS_MAP = {
"/about": AboutSectionLink,
"/u": UsersSectionLink,
"/faq": FAQSectionLink,
"/my/activity": MyDraftsSectionLink,
"/my/activity": MyPostsSectionLink,
"/review": ReviewSectionLink,
"/badges": BadgesSectionLink,
"/admin": AdminSectionLink,

View File

@@ -4,13 +4,13 @@ import { i18n } from "discourse-i18n";
const USER_DRAFTS_CHANGED_EVENT = "user-drafts:changed";
export default class MyDraftsSectionLink extends BaseSectionLink {
export default class MyPostsSectionLink extends BaseSectionLink {
@tracked draftCount = this.currentUser?.draft_count;
constructor() {
super(...arguments);
if (this.currentUser) {
if (this.shouldDisplay) {
this.appEvents.on(
USER_DRAFTS_CHANGED_EVENT,
this,
@@ -20,7 +20,7 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
}
teardown() {
if (this.currentUser) {
if (this.shouldDisplay) {
this.appEvents.off(
USER_DRAFTS_CHANGED_EVENT,
this,
@@ -38,11 +38,21 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
}
get name() {
return "my-drafts";
return "my-posts";
}
get route() {
return "userActivity.drafts";
if (this._hasDraft) {
return "userActivity.drafts";
} else {
return "userActivity.index";
}
}
get currentWhen() {
if (this._hasDraft) {
return "userActivity.index userActivity.drafts";
}
}
get model() {
@@ -50,11 +60,24 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
}
get title() {
return i18n("sidebar.sections.community.links.my_drafts.title");
if (this._hasDraft) {
return i18n("sidebar.sections.community.links.my_posts.title_drafts");
} else {
return i18n("sidebar.sections.community.links.my_posts.title");
}
}
get text() {
return i18n("sidebar.sections.community.links.my_drafts.content");
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return i18n("sidebar.sections.community.links.my_posts.content_drafts");
} else {
return i18n(
`sidebar.sections.community.links.${this.overridenName
.toLowerCase()
.replace(" ", "_")}.content`,
{ defaultValue: this.overridenName }
);
}
}
get badgeText() {
@@ -65,7 +88,7 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
if (this.currentUser.new_new_view_enabled) {
return this.draftCount.toString();
} else {
return i18n("sidebar.sections.community.links.my_drafts.draft_count", {
return i18n("sidebar.sections.community.links.my_posts.draft_count", {
count: this.draftCount,
});
}
@@ -75,6 +98,13 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
return this.draftCount > 0;
}
get defaultPrefixValue() {
if (this._hasDraft && this.currentUser?.new_new_view_enabled) {
return "pencil";
}
return "user";
}
get suffixCSSClass() {
return "unread";
}
@@ -90,10 +120,6 @@ export default class MyDraftsSectionLink extends BaseSectionLink {
}
get shouldDisplay() {
return this.currentUser && this._hasDraft;
}
get prefixValue() {
return "far-pen-to-square";
return this.currentUser;
}
}

View File

@@ -1,5 +1,6 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { schedule } from "@ember/runloop";
import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import $ from "jquery";
@@ -19,6 +20,12 @@ import {
} from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
/**
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
*/
const INDENT_DIRECTION_LEFT = "left";
const INDENT_DIRECTION_RIGHT = "right";
@@ -33,9 +40,13 @@ const OP = {
const FOUR_SPACES_INDENT = "4-spaces-indent";
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
export function getHead(head, prev) {
/**
* Our head can be a static string or a function that returns a string
* based on input (like for numbered lists).
*
* @returns {[string, number]}
*/
function getHead(head, prev) {
if (typeof head === "string") {
return [head, head.length];
} else {
@@ -43,12 +54,15 @@ export function getHead(head, prev) {
}
}
/** @implements {TextManipulation} */
export default class TextareaTextManipulation {
@service appEvents;
@service siteSettings;
@service capabilities;
@service currentUser;
allowPreview = true;
eventPrefix;
textarea;
$textarea;
@@ -816,11 +830,18 @@ export default class TextareaTextManipulation {
}
putCursorAtEnd() {
putCursorAtEnd(this.textarea);
if (this.capabilities.isIOS) {
putCursorAtEnd(this.textarea);
} else {
// in some browsers, the focus() called by putCursorAtEnd doesn't bubble the event to set
// isEditorFoused=true and bring the focus indicator to the wrapper, unless we do it on next tick
next(() => putCursorAtEnd(this.textarea));
}
}
autocomplete(options) {
return this.$textarea.autocomplete(
// @ts-ignore
this.$textarea.autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
@@ -838,6 +859,7 @@ function insertAtTextarea(textarea, start, end, text) {
}
}
/** @implements {AutocompleteHandler} */
export class TextareaAutocompleteHandler {
textarea;
$textarea;
@@ -847,12 +869,13 @@ export class TextareaAutocompleteHandler {
this.$textarea = $(textarea);
}
get value() {
getValue() {
return this.textarea.value;
}
replaceTerm({ start, end, term }) {
const space = this.value.substring(end + 1, end + 2) === " " ? "" : " ";
replaceTerm(start, end, term) {
const space =
this.getValue().substring(end + 1, end + 2) === " " ? "" : " ";
insertAtTextarea(this.textarea, start, end + 1, term + space);
setCaretPosition(this.textarea, start + 1 + term.trim().length);
}
@@ -862,6 +885,7 @@ export class TextareaAutocompleteHandler {
}
getCaretCoords(start) {
// @ts-ignore
return this.$textarea.caretPosition({ pos: start + 1 });
}
@@ -873,9 +897,11 @@ export class TextareaAutocompleteHandler {
}
}
/** @implements {PlaceholderHandler} */
class TextareaPlaceholderHandler {
@service composer;
/** @type {TextareaTextManipulation} */
textManipulation;
#placeholders = {};

View File

@@ -345,11 +345,6 @@ export function applyValueTransformer(
try {
const value = valueCallback({ value: newValue, context });
if (mutable && typeof value !== "undefined") {
throw new Error(
`${prefix}: transformer "${transformerName}" expects the value to be mutated instead of returned. Remove the return value in your transformer.`
);
}
if (!mutable) {
newValue = value;

View File

@@ -52,7 +52,8 @@ export default class UppyComposerUpload {
uploadPreProcessors;
uploadHandlers;
textManipulation;
/** @type {PlaceholderHandler} */
placeholderHandler;
#inProgressUploads = [];
#bufferedUploadErrors = [];
@@ -334,7 +335,7 @@ export default class UppyComposerUpload {
})
);
this.textManipulation.placeholder.insert(file);
this.placeholderHandler.insert(file);
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-started`,
@@ -369,7 +370,7 @@ export default class UppyComposerUpload {
file,
upload.url,
() => {
this.textManipulation.placeholder.success(file, markdown);
this.placeholderHandler.success(file, markdown);
this.appEvents.trigger(
`${this.composerEventPrefix}:upload-success`,
@@ -395,7 +396,7 @@ export default class UppyComposerUpload {
this.uppyWrapper.uppyInstance.on("cancel-all", () => {
// Do the manual cancelling work only if the user clicked cancel
if (this.#userCancelled) {
this.textManipulation.placeholder.cancelAll();
this.placeholderHandler.cancelAll();
this.#userCancelled = false;
this.#reset();
@@ -480,13 +481,13 @@ export default class UppyComposerUpload {
});
this.uppyWrapper.onPreProcessProgress((file) => {
this.textManipulation.placeholder.progress(file);
this.placeholderHandler.progress(file);
});
this.uppyWrapper.onPreProcessComplete(
(file) => {
run(() => {
this.textManipulation.placeholder.progressComplete(file);
this.placeholderHandler.progressComplete(file);
});
},
() => {
@@ -529,13 +530,13 @@ export default class UppyComposerUpload {
}
#resetUpload(file) {
this.textManipulation.placeholder.cancel(file);
this.placeholderHandler.cancel(file);
}
@bind
_pasteEventListener(event) {
if (
document.activeElement !== document.querySelector(this.editorInputClass)
!document.querySelector(this.editorInputClass)?.contains(event.target)
) {
return;
}
@@ -550,6 +551,7 @@ export default class UppyComposerUpload {
}
if (event && event.clipboardData && event.clipboardData.files) {
event.preventDefault();
this._addFiles([...event.clipboardData.files], { pasted: true });
}
}

View File

@@ -0,0 +1,116 @@
import { tracked } from "@glimmer/tracking";
import { isEmpty } from "@ember/utils";
import discourseDebounce from "discourse/lib/debounce";
import User from "discourse/models/user";
import { i18n } from "discourse-i18n";
function failedResult(attrs) {
return {
shouldCheck: false,
failed: true,
ok: false,
element: document.querySelector("#new-account-username"),
...attrs,
};
}
function validResult(attrs) {
return { ok: true, ...attrs };
}
export default class UsernameValidationHelper {
@tracked usernameValidationResult;
checkedUsername = null;
constructor(owner) {
this.owner = owner;
}
async fetchExistingUsername() {
const result = await User.checkUsername(null, this.owner.accountEmail);
if (
result.suggestion &&
(isEmpty(this.owner.accountUsername) ||
this.owner.accountUsername === this.owner.get("authOptions.username"))
) {
this.owner.accountUsername = result.suggestion;
this.owner.prefilledUsername = result.suggestion;
}
}
get usernameValidation() {
if (
this.usernameValidationResult &&
this.checkedUsername === this.owner.accountUsername
) {
return this.usernameValidationResult;
}
const result = this.basicUsernameValidation(this.owner.accountUsername);
if (result.shouldCheck) {
discourseDebounce(this, this.checkUsernameAvailability, 500);
}
return result;
}
basicUsernameValidation(username) {
if (username && username === this.owner.prefilledUsername) {
return validResult({ reason: i18n("user.username.prefilled") });
}
if (isEmpty(username)) {
return failedResult({
message: i18n("user.username.required"),
reason: this.owner.forceValidationReason
? i18n("user.username.required")
: null,
});
}
if (username.length < this.owner.siteSettings.min_username_length) {
return failedResult({ reason: i18n("user.username.too_short") });
}
if (username.length > this.owner.siteSettings.max_username_length) {
return failedResult({ reason: i18n("user.username.too_long") });
}
return failedResult({
shouldCheck: true,
reason: i18n("user.username.checking"),
});
}
async checkUsernameAvailability() {
const result = await User.checkUsername(
this.owner.accountUsername,
this.owner.accountEmail
);
if (this.owner.isDestroying || this.owner.isDestroyed) {
return;
}
this.checkedUsername = this.owner.accountUsername;
this.owner.isDeveloper = !!result.is_developer;
if (result.available) {
this.usernameValidationResult = validResult({
reason: i18n("user.username.available"),
});
} else if (result.suggestion) {
this.usernameValidationResult = failedResult({
reason: i18n("user.username.not_available", result),
});
} else {
this.usernameValidationResult = failedResult({
reason: result.errors
? result.errors.join(" ")
: i18n("user.username.not_available_no_suggestion"),
});
}
}
}

View File

@@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import EmberObject, { action, computed } from "@ember/object";
import { alias, and, or, reads } from "@ember/object/computed";
import { cancel, scheduleOnce } from "@ember/runloop";
@@ -27,7 +28,6 @@ import { getOwnerWithFallback } from "discourse/lib/get-owner";
import getURL from "discourse/lib/get-url";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { buildQuote } from "discourse/lib/quote";
import { emojiUnescape } from "discourse/lib/text";
import {
authorizesOneOrMoreExtensions,
@@ -106,6 +106,8 @@ export default class ComposerService extends Service {
@service siteSettings;
@service store;
@tracked showPreview = true;
@tracked allowPreview = true;
checkedMessages = false;
messageCount = null;
showEditReason = false;
@@ -119,7 +121,7 @@ export default class ComposerService extends Service {
uploadProgress;
topic = null;
linkLookup = null;
showPreview = true;
composerHeight = null;
@and("site.mobileView", "showPreview") forcePreview;
@@ -135,6 +137,10 @@ export default class ComposerService extends Service {
return getOwnerWithFallback(this).lookup("controller:topic");
}
get isPreviewVisible() {
return this.showPreview && this.allowPreview;
}
get isOpen() {
return this.model?.composeState === Composer.OPEN;
}
@@ -841,45 +847,6 @@ export default class ComposerService extends Service {
return false;
}
// Import a quote from the post
@action
async importQuote(toolbarEvent) {
const postStream = this.get("topic.postStream");
let postId = this.get("model.post.id");
// If there is no current post, use the first post id from the stream
if (!postId && postStream) {
postId = postStream.get("stream.firstObject");
}
// If we're editing a post, fetch the reply when importing a quote
if (this.get("model.editingPost")) {
const replyToPostNumber = this.get("model.post.reply_to_post_number");
if (replyToPostNumber) {
const replyPost = postStream.posts.findBy(
"post_number",
replyToPostNumber
);
if (replyPost) {
postId = replyPost.id;
}
}
}
if (!postId) {
return;
}
this.set("model.loading", true);
const post = await this.store.find("post", postId);
const quote = buildQuote(post, post.raw, { full: true });
toolbarEvent.addText(quote);
this.set("model.loading", false);
}
@action
saveAction(ignore, event) {
this.save(false, {

View File

@@ -32,6 +32,7 @@ export const CRITICAL_DEPRECATIONS = [
"discourse.qunit.acceptance-function",
"discourse.qunit.global-exists",
"discourse.post-stream.trigger-new-post",
"discourse.hbr-topic-list-overrides",
];
if (DEBUG) {

View File

@@ -4,7 +4,7 @@ import loadPluginFeatures from "./features";
import MentionsParser from "./mentions-parser";
import buildOptions from "./options";
function buildEngine(options) {
export function buildEngine(options) {
return DiscourseMarkdownIt.withCustomFeatures(
loadPluginFeatures()
).withOptions(buildOptions(options));

View File

@@ -0,0 +1,249 @@
// @ts-check
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import "../extensions/register-default";
import { baseKeymap } from "prosemirror-commands";
import * as ProsemirrorCommands from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
import * as ProsemirrorHistory from "prosemirror-history";
import { history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import * as ProsemirrorModel from "prosemirror-model";
import * as ProsemirrorState from "prosemirror-state";
import { EditorState } from "prosemirror-state";
import * as ProsemirrorTransform from "prosemirror-transform";
import * as ProsemirrorView from "prosemirror-view";
import { EditorView } from "prosemirror-view";
import { getExtensions } from "discourse/lib/composer/rich-editor-extensions";
import { bind } from "discourse/lib/decorators";
import { buildInputRules } from "../core/inputrules";
import { buildKeymap } from "../core/keymap";
import Parser from "../core/parser";
import { extractNodeViews, extractPlugins } from "../core/plugin";
import { createSchema } from "../core/schema";
import Serializer from "../core/serializer";
import placeholder from "../extensions/placeholder";
import * as utils from "../lib/plugin-utils";
import TextManipulation from "../lib/text-manipulation";
const AUTOCOMPLETE_KEY_DOWN_SUPPRESS = ["Enter", "Tab"];
/**
* @typedef ProsemirrorEditorArgs
* @property {string} [value] The markdown content to be rendered in the editor
* @property {string} [placeholder] The placeholder text to be displayed when the editor is empty
* @property {boolean} [disabled] Whether the editor should be disabled
* @property {Record<string, () => void>} [keymap] A mapping of keybindings to commands
* @property {(value: { target: { value: string } }) => void} [change] A callback called when the editor content changes
* @property {() => void} [focusIn] A callback called when the editor gains focus
* @property {() => void} [focusOut] A callback called when the editor loses focus
* @property {(textManipulation: TextManipulation) => undefined | (() => void)} [onSetup] A callback called when the editor is set up, may return a destructor
* @property {number} [topicId] The ID of the topic being edited, if any
* @property {number} [categoryId] The ID of the category of the topic being edited, if any
* @property {string} [class] The class to be added to the ProseMirror contentEditable editor
* @property {boolean} [includeDefault] If default node and mark spec/parse/serialize/inputRules definitions from ProseMirror should be included
* @property {import("discourse/lib/composer/rich-editor-extensions").RichEditorExtension[]} [extensions] A list of extensions to be used with the editor INSTEAD of the ones registered through the API
*/
/**
* @typedef ProsemirrorEditorSignature
* @property {ProsemirrorEditorArgs} Args
*/
/**
* @extends {Component<ProsemirrorEditorSignature>}
*/
export default class ProsemirrorEditor extends Component {
@service session;
@service dialog;
schema = createSchema(this.extensions, this.args.includeDefault);
view;
#lastSerialized;
/** @type {undefined | (() => void)} */
#destructor;
get pluginParams() {
return {
utils,
schema: this.schema,
pmState: ProsemirrorState,
pmModel: ProsemirrorModel,
pmView: ProsemirrorView,
pmHistory: ProsemirrorHistory,
pmTransform: ProsemirrorTransform,
pmCommands: ProsemirrorCommands,
getContext: () => ({
placeholder: this.args.placeholder,
topicId: this.args.topicId,
categoryId: this.args.categoryId,
session: this.session,
}),
};
}
get extensions() {
const extensions = this.args.extensions ?? getExtensions();
// enforcing core extensions
return extensions.includes(placeholder)
? extensions
: [placeholder, ...extensions];
}
get keymapFromArgs() {
const replacements = { tab: "Tab" };
const result = {};
for (const [key, value] of Object.entries(this.args.keymap ?? {})) {
const pmKey = key
.split("+")
.map((word) => replacements[word] ?? word)
.join("-");
result[pmKey] = value;
}
return result;
}
@action
handleAsyncPlugin(plugin) {
const state = this.view.state.reconfigure({
plugins: [...this.view.state.plugins, plugin],
});
this.view.updateState(state);
}
@action
setup(container) {
const params = this.pluginParams;
const plugins = [
buildInputRules(this.extensions, this.schema, this.args.includeDefault),
keymap(
buildKeymap(
this.extensions,
this.schema,
this.keymapFromArgs,
params,
this.args.includeDefault
)
),
keymap(baseKeymap),
dropCursor({ color: "var(--primary)" }),
gapCursor(),
history(),
...extractPlugins(this.extensions, params, this.handleAsyncPlugin),
];
this.parser = new Parser(this.extensions, this.args.includeDefault);
this.serializer = new Serializer(this.extensions, this.args.includeDefault);
const state = EditorState.create({ schema: this.schema, plugins });
this.view = new EditorView(container, {
state,
nodeViews: extractNodeViews(this.extensions),
attributes: { class: this.args.class },
editable: () => this.args.disabled !== true,
dispatchTransaction: (tr) => {
this.view.updateState(this.view.state.apply(tr));
if (tr.docChanged && tr.getMeta("addToHistory") !== false) {
// If this gets expensive, we can debounce it
const value = this.serializer.convert(this.view.state.doc);
this.#lastSerialized = value;
this.args.change?.({ target: { value } });
}
},
handleDOMEvents: {
focus: () => {
this.args.focusIn?.();
return false;
},
blur: () => {
next(() => this.args.focusOut?.());
return false;
},
},
handleKeyDown: (view, event) => {
// suppress if Enter/Tab and the autocomplete is open
return (
AUTOCOMPLETE_KEY_DOWN_SUPPRESS.includes(event.key) &&
!!document.querySelector(".autocomplete")
);
},
});
this.textManipulation = new TextManipulation(getOwner(this), {
schema: this.schema,
view: this.view,
convertFromMarkdown: this.convertFromMarkdown,
convertToMarkdown: this.serializer.convert.bind(this.serializer),
});
this.#destructor = this.args.onSetup?.(this.textManipulation);
this.convertFromValue();
}
@bind
convertFromMarkdown(markdown) {
try {
return this.parser.convert(this.schema, markdown);
} catch (e) {
next(() => this.dialog.alert(e.message));
throw e;
}
}
@bind
convertFromValue() {
// Ignore the markdown we just serialized
if (this.args.value === this.#lastSerialized) {
return;
}
const doc = this.convertFromMarkdown(this.args.value);
const tr = this.view.state.tr;
tr.replaceWith(0, this.view.state.doc.content.size, doc.content).setMeta(
"addToHistory",
false
);
this.view.updateState(this.view.state.apply(tr));
}
@action
teardown() {
this.#destructor?.();
this.view.destroy();
}
@action
updateContext(element, [key, value]) {
this.view.dispatch(
this.view.state.tr
.setMeta("addToHistory", false)
.setMeta("discourseContextChanged", { key, value })
);
}
<template>
<div
class="ProseMirror-container"
{{didInsert this.setup}}
{{didUpdate this.convertFromValue @value}}
{{didUpdate this.updateContext "placeholder" @placeholder}}
{{willDestroy this.teardown}}
>
</div>
</template>
}

View File

@@ -0,0 +1,128 @@
import {
InputRule,
inputRules,
smartQuotes,
textblockTypeInputRule,
wrappingInputRule,
} from "prosemirror-inputrules";
export function buildInputRules(extensions, schema, includeDefault = true) {
const rules = [];
if (includeDefault) {
rules.push(
// TODO(renato) smartQuotes should respect `markdown_typographer_quotation_marks`
...smartQuotes,
...[
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
orderedListRule(schema.nodes.ordered_list),
bulletListRule(schema.nodes.bullet_list),
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
textblockTypeInputRule(/^ {4}$/, schema.nodes.code_block),
headingRule(schema.nodes.heading, 6),
markInputRule(/\*\*([^*]+)\*\*$/, schema.marks.strong),
markInputRule(/(?<=^|\s)__([^_]+)__$/, schema.marks.strong),
markInputRule(/(?:^|(?<!\*))\*([^*]+)\*$/, schema.marks.em),
markInputRule(/(?<=^|\s)_([^_]+)_$/, schema.marks.em),
markInputRule(/`([^`]+)`$/, schema.marks.code),
]
);
}
rules.push(...extractInputRules(extensions, schema));
return inputRules({ rules });
}
function extractInputRules(extensions, schema) {
return extensions.flatMap(({ inputRules: extensionRules }) =>
extensionRules ? processInputRule(extensionRules, schema) : []
);
}
function processInputRule(inputRule, schema) {
if (inputRule instanceof Array) {
return inputRule.map((rule) => processInputRule(rule, schema));
}
if (inputRule instanceof Function) {
inputRule = inputRule({ schema, markInputRule });
}
if (inputRule instanceof InputRule) {
return inputRule;
}
if (
inputRule.match instanceof RegExp &&
inputRule.handler instanceof Function
) {
return new InputRule(inputRule.match, inputRule.handler, inputRule.options);
}
throw new Error("Input rule must have a match regex and a handler function");
}
function orderedListRule(nodeType) {
return wrappingInputRule(
/^(\d+)\.\s$/,
nodeType,
(match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1]
);
}
function bulletListRule(nodeType) {
return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
}
function headingRule(nodeType, maxLevel) {
return textblockTypeInputRule(
new RegExp("^(#{1," + maxLevel + "})\\s$"),
nodeType,
(match) => ({ level: match[1].length })
);
}
// https://discuss.prosemirror.net/t/input-rules-for-wrapping-marks/537
function markInputRule(regexp, markType, getAttrs) {
return new InputRule(regexp, (state, match, start, end) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
const tr = state.tr;
if (state.doc.rangeHasMark(start, end, markType)) {
return false;
}
if (match[1]) {
let textStart = start + match[0].indexOf(match[1]);
let textEnd = textStart + match[1].length;
if (textEnd < end) {
tr.delete(textEnd, end);
}
if (textStart > start) {
tr.delete(start, textStart);
}
end = start + match[1].length;
tr.addMark(start, end, markType.create(attrs));
tr.removeStoredMark(markType);
} else {
tr.delete(start, end);
tr.insertText(" ");
tr.addMark(start, start + 1, markType.create(attrs));
tr.removeStoredMark(markType);
tr.insertText(" ");
tr.setSelection(
state.selection.constructor.create(tr.doc, start, start + 1)
);
}
return tr;
});
}

View File

@@ -0,0 +1,77 @@
import {
chainCommands,
exitCode,
selectParentNode,
setBlockType,
} from "prosemirror-commands";
import { redo, undo } from "prosemirror-history";
import { undoInputRule } from "prosemirror-inputrules";
import { splitListItem } from "prosemirror-schema-list";
const isMac =
typeof navigator !== "undefined"
? /Mac|iP(hone|[oa]d)/.test(navigator.platform)
: false;
export function buildKeymap(
extensions,
schema,
initialKeymap,
params,
includeDefault = true
) {
const keys = {
...initialKeymap,
...extractKeymap(extensions, params),
};
keys["Mod-z"] = undo;
keys["Shift-Mod-z"] = redo;
keys["Backspace"] = undoInputRule;
if (!isMac) {
keys["Mod-y"] = redo;
}
keys["Escape"] = selectParentNode;
// The above keys are always included
if (!includeDefault) {
return keys;
}
keys["Shift-Enter"] = chainCommands(exitCode, (state, dispatch) => {
if (dispatch) {
dispatch(
state.tr
.replaceSelectionWith(schema.nodes.hard_break.create())
.scrollIntoView()
);
}
return true;
});
keys["Mod-Shift-0"] = setBlockType(schema.nodes.paragraph);
keys["Enter"] = splitListItem(schema.nodes.list_item);
for (let level = 1; level <= 6; level++) {
keys["Mod-Shift-" + level] = setBlockType(schema.nodes.heading, { level });
}
keys["Mod-Shift-_"] = (state, dispatch) => {
dispatch?.(
state.tr
.replaceSelectionWith(schema.nodes.horizontal_rule.create())
.scrollIntoView()
);
return true;
};
return keys;
}
function extractKeymap(extensions, params) {
return {
...extensions.map(({ keymap }) => {
return keymap instanceof Function ? keymap(params) : keymap;
}),
};
}

View File

@@ -0,0 +1,84 @@
import { defaultMarkdownParser, MarkdownParser } from "prosemirror-markdown";
import { parse } from "../lib/markdown-it";
// TODO(renato): We need a workaround for this parsing issue:
// https://github.com/ProseMirror/prosemirror-markdown/issues/82
// a solution may be a markStack in the state ignoring nested marks
export default class Parser {
#multipleParseSpecs = {};
constructor(extensions, includeDefault = true) {
this.parseTokens = includeDefault
? {
...defaultMarkdownParser.tokens,
bbcode_b: { mark: "strong" },
bbcode_i: { mark: "em" },
}
: {};
this.postParseTokens = includeDefault
? { softbreak: (state) => state.addNode(state.schema.nodes.hard_break) }
: {};
for (const [key, value] of Object.entries(
this.#extractParsers(extensions)
)) {
// Not a ParseSpec
if (typeof value === "function") {
this.postParseTokens[key] = value;
} else {
this.parseTokens[key] = value;
}
}
}
convert(schema, text) {
const parser = new MarkdownParser(schema, { parse }, this.parseTokens);
// Adding function parse handlers directly
Object.assign(parser.tokenHandlers, this.postParseTokens);
return parser.parse(text);
}
#extractParsers(extensions) {
const parsers = {};
for (const { parse: parseObj } of extensions) {
if (!parseObj) {
continue;
}
for (const [token, parseSpec] of Object.entries(parseObj)) {
if (parsers[token] !== undefined) {
if (this.#multipleParseSpecs[token] === undefined) {
// switch to use multipleParseSpecs
this.#multipleParseSpecs[token] = [parsers[token]];
parsers[token] = this.#multipleParser(token);
}
this.#multipleParseSpecs[token].push(parseSpec);
continue;
}
parsers[token] = parseSpec;
}
}
return parsers;
}
#multipleParser(tokenName) {
return (state, token, tokens, i) => {
const parseSpecs = this.#multipleParseSpecs[tokenName];
for (const parseSpec of parseSpecs) {
if (parseSpec(state, token, tokens, i)) {
return;
}
}
throw new Error(
`No parser processed ${tokenName} token for tag: ${
token.tag
}, attrs: ${JSON.stringify(token.attrs)}`
);
};
}
}

View File

@@ -0,0 +1,49 @@
import { Plugin } from "prosemirror-state";
export function extractNodeViews(extensions) {
/** @type {Record<string, import('prosemirror-view').NodeViewConstructor>} */
const allNodeViews = {};
for (const { nodeViews } of extensions) {
if (nodeViews) {
for (const [name, NodeViewClass] of Object.entries(nodeViews)) {
allNodeViews[name] = (...args) => new NodeViewClass(...args);
}
}
}
return allNodeViews;
}
export function extractPlugins(extensions, params, view) {
return (
extensions
.flatMap((extension) => extension.plugins || [])
.flatMap((plugin) => processPlugin(plugin, params, view))
// filter async plugins from initial load
.filter(Boolean)
);
}
function processPlugin(pluginArg, params, handleAsyncPlugin) {
if (typeof pluginArg === "function") {
const ret = pluginArg(params);
if (ret instanceof Promise) {
ret.then((plugin) => handleAsyncPlugin(processPlugin(plugin, params)));
return;
}
return processPlugin(ret, params, handleAsyncPlugin);
}
if (pluginArg instanceof Array) {
return pluginArg.map((plugin) =>
processPlugin(plugin, params, handleAsyncPlugin)
);
}
if (pluginArg instanceof Plugin) {
return pluginArg;
}
return new Plugin(pluginArg);
}

View File

@@ -0,0 +1,41 @@
import OrderedMap from "orderedmap";
import { schema as defaultMarkdownSchema } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
export function createSchema(extensions, includeDefault = true) {
let nodes = includeDefault
? defaultMarkdownSchema.spec.nodes
: new OrderedMap([]);
let marks = includeDefault
? defaultMarkdownSchema.spec.marks
: new OrderedMap([]);
for (const [type, spec] of Object.entries(extractNodes(extensions))) {
nodes = nodes.update(type, spec);
}
for (const [type, spec] of Object.entries(extractMarks(extensions))) {
marks = spec.before
? marks.addBefore(spec.before, type, spec)
: marks.update(type, spec);
}
return new Schema({ nodes, marks });
}
function extractNodes(extensions) {
const nodes = {};
for (const extension of extensions) {
Object.assign(nodes, extension.nodeSpec);
}
return nodes;
}
function extractMarks(extensions) {
const marks = {};
for (const extension of extensions) {
Object.assign(marks, extension.markSpec);
}
return marks;
}

View File

@@ -0,0 +1,36 @@
import {
defaultMarkdownSerializer,
MarkdownSerializer,
} from "prosemirror-markdown";
export default class Serializer {
#pmSerializer;
constructor(extensions, includeDefault = true) {
this.nodes = includeDefault ? { ...defaultMarkdownSerializer.nodes } : {};
this.nodes.hard_break = (state) => state.write("\n");
this.marks = includeDefault ? { ...defaultMarkdownSerializer.marks } : {};
this.#extractNodeSerializers(extensions);
this.#extractMarkSerializers(extensions);
this.#pmSerializer = new MarkdownSerializer(this.nodes, this.marks);
}
convert(doc) {
return this.#pmSerializer.serialize(doc);
}
#extractNodeSerializers(extensions) {
for (const { serializeNode } of extensions) {
Object.assign(this.nodes, serializeNode);
}
}
#extractMarkSerializers(extensions) {
for (const { serializeMark } of extensions) {
Object.assign(this.marks, serializeMark);
}
}
}

View File

@@ -0,0 +1,56 @@
/**
* This extension is considered a "core" extension, it's autoloaded by ProsemirrorEditor
*
* @type {RichEditorExtension}
*/
const extension = {
plugins({
pmState: { Plugin },
pmView: { Decoration, DecorationSet },
getContext,
}) {
let placeholder;
return new Plugin({
view() {
placeholder = getContext().placeholder;
return {};
},
state: {
init() {
return placeholder;
},
apply(tr) {
const contextChanged = tr.getMeta("discourseContextChanged");
if (contextChanged?.key === "placeholder") {
placeholder = contextChanged.value;
}
return placeholder;
},
},
props: {
decorations(state) {
const { $head } = state.selection;
if (
state.doc.childCount === 1 &&
state.doc.firstChild === $head.parent &&
isEmptyParagraph($head.parent)
) {
const decoration = Decoration.node($head.before(), $head.after(), {
"data-placeholder": this.getState(state),
});
return DecorationSet.create(state.doc, [decoration]);
}
},
},
});
},
};
function isEmptyParagraph(node) {
return node.type.name === "paragraph" && node.nodeSize === 2;
}
export default extension;

View File

@@ -0,0 +1,27 @@
import { buildEngine } from "discourse/static/markdown-it";
import loadPluginFeatures from "discourse/static/markdown-it/features";
import defaultFeatures from "discourse-markdown-it/features/index";
let engine;
function getEngine() {
engine ??= buildEngine({
featuresOverride: [...defaultFeatures, ...loadPluginFeatures()]
.map(({ id }) => id)
// Avoid oneboxing when parsing, we'll handle that separately
.filter((id) => id !== "onebox"),
});
return engine;
}
export const parse = (text) => getEngine().parse(text);
export const getLinkify = () => getEngine().linkify;
export const isBoundary = (str, index) =>
!str ||
getEngine().options.engine.utils.isWhiteSpace(str.charCodeAt(index)) ||
getEngine().options.engine.utils.isPunctChar(
String.fromCharCode(str.charCodeAt(index))
);

View File

@@ -0,0 +1 @@
export { getLinkify, isBoundary } from "../lib/markdown-it";

View File

@@ -0,0 +1,490 @@
// @ts-check
import { setOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import $ from "jquery";
import { lift, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
import { liftListItem, sinkListItem } from "prosemirror-schema-list";
import { TextSelection } from "prosemirror-state";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
/**
* @typedef {import("discourse/lib/composer/text-manipulation").TextManipulation} TextManipulation
* @typedef {import("discourse/lib/composer/text-manipulation").AutocompleteHandler} AutocompleteHandler
* @typedef {import("discourse/lib/composer/text-manipulation").PlaceholderHandler} PlaceholderHandler
*/
/** @implements {TextManipulation} */
export default class ProsemirrorTextManipulation {
allowPreview = false;
/** @type {import("prosemirror-model").Schema} */
schema;
/** @type {import("prosemirror-view").EditorView} */
view;
/** @type {PlaceholderHandler} */
placeholder;
/** @type {AutocompleteHandler} */
autocompleteHandler;
convertFromMarkdown;
convertToMarkdown;
constructor(owner, { schema, view, convertFromMarkdown, convertToMarkdown }) {
setOwner(this, owner);
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
this.convertToMarkdown = convertToMarkdown;
this.placeholder = new ProsemirrorPlaceholderHandler({
schema,
view,
convertFromMarkdown,
});
this.autocompleteHandler = new ProsemirrorAutocompleteHandler({
schema,
view,
convertFromMarkdown,
});
}
getSelected() {
const start = this.view.state.selection.from;
const end = this.view.state.selection.to;
const value = this.view.state.doc.textBetween(start, end, " ", " ");
return {
start,
end,
pre: "",
value,
post: "",
};
}
focus() {
this.view.focus();
}
blurAndFocus() {
this.focus();
}
putCursorAtEnd() {
this.focus();
next(() => (this.view.dom.scrollTop = this.view.dom.scrollHeight));
}
autocomplete(options) {
// @ts-ignore
$(this.view.dom).autocomplete(
options instanceof Object
? { textHandler: this.autocompleteHandler, ...options }
: options
);
}
applySurroundSelection(head, tail, exampleKey) {
this.applySurround(this.getSelected(), head, tail, exampleKey);
}
applySurround(sel, head, tail, exampleKey) {
const applySurroundMap = {
italic_text: this.schema.marks.em,
bold_text: this.schema.marks.strong,
code_title: this.schema.marks.code,
};
if (applySurroundMap[exampleKey]) {
toggleMark(applySurroundMap[exampleKey])(
this.view.state,
this.view.dispatch
);
return;
}
const text = head + i18n(`composer.${exampleKey}`) + tail;
const doc = this.convertFromMarkdown(text);
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, doc.content.firstChild)
);
}
addText(sel, text) {
const doc = this.convertFromMarkdown(text);
// assumes it returns a single block node
const content =
doc.content.firstChild.type.name === "paragraph"
? doc.content.firstChild.content
: doc.content.firstChild;
this.view.dispatch(
this.view.state.tr.replaceWith(sel.start, sel.end, content)
);
this.focus();
}
insertBlock(block) {
const doc = this.convertFromMarkdown(block);
const node = doc.content.firstChild;
const tr = this.view.state.tr.replaceSelectionWith(node);
if (!tr.selection.$from.nodeAfter) {
tr.setSelection(new TextSelection(tr.doc.resolve(tr.selection.from + 1)));
}
this.view.dispatch(tr);
this.focus();
}
applyList(_selection, head, exampleKey) {
let command;
const isInside = (type) => {
const $from = this.view.state.selection.$from;
for (let depth = $from.depth; depth > 0; depth--) {
const parent = $from.node(depth);
if (parent.type === type) {
return true;
}
}
return false;
};
if (exampleKey === "list_item") {
const nodeType =
head === "* "
? this.schema.nodes.bullet_list
: this.schema.nodes.ordered_list;
command = isInside(this.schema.nodes.list_item) ? lift : wrapIn(nodeType);
} else if (exampleKey === "blockquote_text") {
command = isInside(this.schema.nodes.blockquote)
? lift
: wrapIn(this.schema.nodes.blockquote);
} else {
throw new Error("Unknown exampleKey");
}
command?.(this.view.state, this.view.dispatch);
}
formatCode() {
let command;
const selection = this.view.state.selection;
if (selection.$from.parent.type === this.schema.nodes.code_block) {
command = setBlockType(this.schema.nodes.paragraph);
} else if (
selection.$from.pos !== selection.$to.pos &&
selection.$from.parent === selection.$to.parent
) {
command = toggleMark(this.schema.marks.code);
} else {
command = setBlockType(this.schema.nodes.code_block);
}
command?.(this.view.state, this.view.dispatch);
}
@bind
emojiSelected(code) {
this.view.dispatch(
this.view.state.tr
.replaceSelectionWith(this.schema.nodes.emoji.create({ code }))
.insertText(" ")
);
}
@bind
paste() {
// Intentionally no-op
// Pasting markdown is being handled by the markdown-paste extension
// Pasting a url on top of a text is being handled by the link extension
}
selectText(from, length, opts) {
const tr = this.view.state.tr.setSelection(
new TextSelection(
this.view.state.doc.resolve(from),
this.view.state.doc.resolve(from + length)
)
);
if (opts.scroll) {
tr.scrollIntoView();
}
this.view.dispatch(tr);
}
@bind
inCodeBlock() {
return this.autocompleteHandler.inCodeBlock();
}
indentSelection(direction) {
const { selection } = this.view.state;
const isInsideListItem =
selection.$head.depth > 0 &&
selection.$head.node(-1).type === this.schema.nodes.list_item;
if (isInsideListItem) {
const command =
direction === "right"
? sinkListItem(this.schema.nodes.list_item)
: liftListItem(this.schema.nodes.list_item);
command(this.view.state, this.view.dispatch);
return true;
}
}
insertText(text) {
const doc = this.convertFromMarkdown(text);
this.view.dispatch(
this.view.state.tr
.replaceSelectionWith(doc.content.firstChild)
.scrollIntoView()
);
this.focus();
}
replaceText(oldValue, newValue, opts = {}) {
// Replacing Markdown text is not reliable and should eventually be deprecated
const markdown = this.convertToMarkdown(this.view.state.doc);
const regex = opts.regex || new RegExp(oldValue, "g");
const index = opts.index || 0;
let matchCount = 0;
const newMarkdown = markdown.replace(regex, (match) => {
if (matchCount++ === index) {
return newValue;
}
return match;
});
if (markdown === newMarkdown) {
return;
}
const newDoc = this.convertFromMarkdown(newMarkdown);
if (!newDoc) {
return;
}
const diff = newValue.length - oldValue.length;
const startOffset = this.view.state.selection.from + diff;
const endOffset = this.view.state.selection.to + diff;
const tr = this.view.state.tr.replaceWith(
0,
this.view.state.doc.content.size,
newDoc.content
);
if (
!opts.skipNewSelection &&
(opts.forceFocus || this.view.dom === document.activeElement)
) {
const adjustedStart = Math.min(startOffset, tr.doc.content.size);
const adjustedEnd = Math.min(endOffset, tr.doc.content.size);
tr.setSelection(TextSelection.create(tr.doc, adjustedStart, adjustedEnd));
}
this.view.dispatch(tr);
}
toggleDirection() {
this.view.dom.dir = this.view.dom.dir === "rtl" ? "ltr" : "rtl";
}
}
/** @implements {AutocompleteHandler} */
class ProsemirrorAutocompleteHandler {
/** @type {import("prosemirror-view").EditorView} */
view;
/** @type {import("prosemirror-model").Schema} */
schema;
convertFromMarkdown;
constructor({ schema, view, convertFromMarkdown }) {
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
}
/**
* The textual value of the selected text block
* @returns {string}
*/
getValue() {
return (
(this.view.state.selection.$head.nodeBefore?.textContent ?? "") +
(this.view.state.selection.$head.nodeAfter?.textContent ?? "") || " "
);
}
/**
* Replaces the term between start-end in the currently selected text block
*
* It uses input rules to convert it to a node if possible
*
* @param {number} start
* @param {number} end
* @param {String} term
*/
replaceTerm(start, end, term) {
const node = this.view.state.selection.$head.nodeBefore;
const from = this.view.state.selection.from - node.nodeSize + start;
const to = this.view.state.selection.from - node.nodeSize + end + 1;
const doc = this.convertFromMarkdown(term);
const tr = this.view.state.tr.replaceWith(
from,
to,
doc.content.firstChild.content
);
tr.insertText(" ", tr.selection.from);
this.view.dispatch(tr);
}
/**
* Gets the textual caret position within the selected text block
*
* @returns {number}
*/
getCaretPosition() {
const node = this.view.state.selection.$head.nodeBefore;
if (!node?.isText) {
return 0;
}
return node.nodeSize;
}
getCaretCoords(start) {
const node = this.view.state.selection.$head.nodeBefore;
const pos = this.view.state.selection.from - node.nodeSize + start;
const { left, top } = this.view.coordsAtPos(pos);
const rootRect = this.view.dom.getBoundingClientRect();
return {
left: left - rootRect.left,
top: top - rootRect.top,
};
}
async inCodeBlock() {
return (
this.view.state.selection.$from.parent.type ===
this.schema.nodes.code_block
);
}
}
/** @implements {PlaceholderHandler} */
class ProsemirrorPlaceholderHandler {
view;
schema;
convertFromMarkdown;
constructor({ schema, view, convertFromMarkdown }) {
this.schema = schema;
this.view = view;
this.convertFromMarkdown = convertFromMarkdown;
}
insert(file) {
const isEmptyParagraph =
this.view.state.selection.$from.parent.type.name === "paragraph" &&
this.view.state.selection.$from.parent.nodeSize === 2;
const imageNode = this.schema.nodes.image.create({
src: URL.createObjectURL(file.data),
alt: i18n("uploading_filename", { filename: file.name }),
title: file.id,
width: 120,
"data-placeholder": true,
});
this.view.dispatch(
this.view.state.tr.insert(
this.view.state.selection.from,
isEmptyParagraph
? imageNode
: this.schema.nodes.paragraph.create(null, imageNode)
)
);
}
progress() {}
progressComplete() {}
cancelAll() {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"]
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
cancel(file) {
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
this.view.dispatch(this.view.state.tr.delete(pos, pos + node.nodeSize));
}
});
}
success(file, markdown) {
/** @type {null | { node: import("prosemirror-model").Node, pos: number }} */
let nodeToReplace = null;
this.view.state.doc.descendants((node, pos) => {
if (
node.type === this.schema.nodes.image &&
node.attrs["data-placeholder"] &&
node.attrs?.title === file.id
) {
nodeToReplace = { node, pos };
return false;
}
return true;
});
if (!nodeToReplace) {
return;
}
// keeping compatibility with plugins that change the upload markdown
const doc = this.convertFromMarkdown(markdown);
this.view.dispatch(
this.view.state.tr.replaceWith(
nodeToReplace.pos,
nodeToReplace.pos + nodeToReplace.node.nodeSize,
doc.content.firstChild.content
)
);
}
}

View File

@@ -17,11 +17,15 @@ import { i18n } from "discourse-i18n";
export default RouteTemplate(
class extends Component {
@service siteSettings;
@service currentUser;
@tracked accountActivated = false;
@tracked isLoading = false;
@tracked needsApproval = false;
@tracked errorMessage = null;
@tracked
errorMessage = this.currentUser
? i18n("user.activate_account.already_done")
: null;
get signupStep() {
if (this.needsApproval) {

View File

@@ -166,9 +166,7 @@ registerButton(
return likeCount(attrs);
}
const className = attrs.liked
? "toggle-like has-like fade-out"
: "toggle-like like";
const className = attrs.liked ? "toggle-like has-like" : "toggle-like like";
const button = {
action: "like",

View File

@@ -34,7 +34,21 @@
"immer": "^10.1.1",
"jspreadsheet-ce": "^4.15.0",
"morphlex": "^0.0.16",
"pretty-text": "workspace:1.0.0"
"orderedmap": "^2.1.1",
"pretty-text": "workspace:1.0.0",
"prosemirror-commands": "^1.6.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-highlightjs": "^0.9.1",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.34.3"
},
"devDependencies": {
"@babel/core": "^7.26.7",
@@ -98,7 +112,7 @@
"ember-modifier": "^4.2.0",
"ember-qunit": "^9.0.1",
"ember-source": "~5.12.0",
"ember-template-imports": "^4.2.0",
"ember-template-imports": "^4.3.0",
"ember-test-selectors": "^7.0.0",
"float-kit": "workspace:1.0.0",
"html-entities": "^2.5.2",
@@ -130,7 +144,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "octane"

View File

@@ -12,6 +12,7 @@ acceptance("Admin - Plugins", function (needs) {
{
id: "some-test-plugin",
name: "some-test-plugin",
humanized_name: "Some test plugin",
about: "Plugin description",
version: "0.1",
url: "https://example.com",
@@ -44,7 +45,7 @@ acceptance("Admin - Plugins", function (needs) {
.dom(
"table.admin-plugins-list .admin-plugins-list__row .admin-plugins-list__name-details .admin-plugins-list__name-with-badges .admin-plugins-list__name"
)
.hasText("Some Test Plugin", "displays the plugin in the table");
.hasText("Some test plugin", "displays the plugin in the table");
assert
.dom(".admin-plugins .admin-config-page .alert-error")

View File

@@ -214,7 +214,7 @@ acceptance("Composer", function (needs) {
"supports keyboard shortcuts"
);
await click("#reply-control a.cancel");
await click("#reply-control button.cancel");
assert.dom(".d-modal").exists("pops up a confirmation dialog");
await click(".d-modal__footer .discard-draft");
@@ -776,7 +776,7 @@ acceptance("Composer", function (needs) {
await visit("/t/topic-with-whisper/960");
await click(".topic-post:nth-of-type(3) button.reply");
await click("#reply-control .save-or-cancel a.cancel");
await click("#reply-control .save-or-cancel button.cancel");
await click(".topic-footer-main-buttons button.create");
await click(".reply-details summary div");
assert

View File

@@ -481,13 +481,50 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
.doesNotExist();
});
test("clicking on my drafts link", async function (assert) {
updateCurrentUser({ draft_count: 1 });
test("clicking on my posts link", async function (assert) {
await visit("/t/280");
await click(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
);
assert.strictEqual(
currentURL(),
`/u/${loggedInUser().username}/activity`,
"should transition to the user's activity url"
);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link.active"
)
.exists({ count: 1 }, "only one link is marked as active");
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.exists("the my posts link is marked as active");
await visit(`/u/${loggedInUser().username}/activity/drafts`);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.doesNotExist(
"the my posts link is not marked as active when user has no drafts and visiting the user activity drafts URL"
);
});
test("clicking on my posts link when user has a draft", async function (assert) {
await visit("/t/280");
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
await click(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts']"
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts']"
);
assert.strictEqual(
@@ -504,24 +541,45 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-drafts'].active"
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.exists("the my drafts link is marked as active");
.exists("the my posts link is marked as active");
await visit(`/u/${loggedInUser().username}/activity`);
assert
.dom(
".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='my-posts'].active"
)
.exists("the my posts link is marked as active");
});
test("my drafts link is visible when user has drafts", async function (assert) {
updateCurrentUser({ draft_count: 1 });
test("my posts title changes when drafts are present", async function (assert) {
await visit("/");
assert
.dom(".sidebar-section-link[data-link-name='my-drafts']")
.exists("my drafts link is displayed when drafts are present");
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasAttribute(
"title",
i18n("sidebar.sections.community.links.my_posts.title"),
"displays the default title when no drafts are present"
);
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
assert
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasAttribute(
"title",
i18n("sidebar.sections.community.links.my_posts.title_drafts"),
"displays the draft title when drafts are present"
);
});
test("my posts changes its text when drafts are present and new new view experiment is enabled", async function (assert) {
updateCurrentUser({
draft_count: 1,
user_option: {
sidebar_show_count_of_new_items: true,
},
@@ -529,17 +587,28 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) {
});
await visit("/");
assert
.dom(".sidebar-section-link[data-link-name='my-posts']")
.hasText(
i18n("sidebar.sections.community.links.my_posts.content"),
"displays the default text when no drafts are present"
);
await publishToMessageBus(`/user-drafts/${loggedInUser().id}`, {
draft_count: 1,
});
assert
.dom(
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-text"
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-text"
)
.hasText(
i18n("sidebar.sections.community.links.my_drafts.content"),
i18n("sidebar.sections.community.links.my_posts.content_drafts"),
"displays the text that's appropriate for when drafts are present"
);
assert
.dom(
".sidebar-section-link[data-link-name='my-drafts'] .sidebar-section-link-content-badge"
".sidebar-section-link[data-link-name='my-posts'] .sidebar-section-link-content-badge"
)
.hasText("1", "displays the draft count with no text");
});

View File

@@ -47,7 +47,7 @@ acceptance("User Drafts", function (needs) {
);
assert
.dom(".user-stream-item:nth-child(2) a.avatar-link")
.hasAttribute("href", "/u/eviltrout", "has correct avatar link");
.dom(".user-stream-item:nth-child(2) .draft-icon .d-icon")
.hasClass("d-icon-reply", "has correct icon");
});
});

View File

@@ -47,4 +47,30 @@ acceptance("Video Placeholder Test", function () {
.hasStyle({ display: "block" }, "The video is no longer hidden");
assert.dom(".video-placeholder-wrapper").doesNotExist();
});
test("displays an error for invalid video URL and allows retry", async function (assert) {
await visit("/t/54081");
const placeholder = document.querySelector(".video-placeholder-container");
placeholder.setAttribute(
"data-video-src",
'http://example.com/video.mp4"><script>alert(1)</script>'
);
await click(".video-placeholder-overlay");
assert
.dom(".video-placeholder-wrapper .notice.error")
.exists("An error message is displayed for an invalid URL");
assert
.dom(".video-placeholder-wrapper .notice.error")
.hasText(
"This video cannot be played because the URL is invalid or unavailable.",
"Error message is correct"
);
assert
.dom("video")
.doesNotExist("No video element is created for invalid URL");
});
});

View File

@@ -0,0 +1,64 @@
import { tracked } from "@glimmer/tracking";
import { click, render, settled, waitFor } from "@ember/test-helpers";
import DEditor from "discourse/components/d-editor";
export async function testMarkdown(
assert,
markdown,
expectedHtml,
expectedMarkdown
) {
const self = new (class {
@tracked value = markdown;
@tracked view;
})();
const handleSetup = (textManipulation) => {
self.view = textManipulation.view;
};
await render(<template>
<DEditor
@value={{self.value}}
@processPreview={{false}}
@onSetup={{handleSetup}}
/>
</template>);
await click(".composer-toggle-switch");
await waitFor(".ProseMirror");
await settled();
const editor = document.querySelector(".ProseMirror");
// typeIn for contentEditable isn't reliable, and is slower
const tr = self.view.state.tr;
// insert a paragraph to enforce serialization
tr.insert(
tr.doc.content.size,
self.view.state.schema.node(
"paragraph",
null,
self.view.state.schema.text("X")
)
);
// then delete it
tr.delete(tr.doc.content.size - 3, tr.doc.content.size);
self.view.dispatch(tr);
await settled();
const html = editor.innerHTML
// we don't care about some PM-specifics
.replace(' class="ProseMirror-selectednode"', "")
.replace('<img class="ProseMirror-separator" alt="">', "")
.replace('<br class="ProseMirror-trailingBreak">', "")
// or artifacts
.replace('class=""', "");
assert.strictEqual(html, expectedHtml, `HTML should match for "${markdown}"`);
assert.strictEqual(
self.value,
expectedMarkdown,
`Markdown should match for "${markdown}"`
);
}

View File

@@ -44,5 +44,31 @@ module(
assert.dom(".form-kit__control-checkbox").hasAttribute("disabled");
});
test("@tooltip", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @tooltip="test" @name="foo" @title="Foo" as |field|>
<field.Checkbox />
</form.Field>
</Form>
</template>);
assert
.dom(".form-kit__control-checkbox-content .form-kit__tooltip")
.exists();
});
test("optional", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Checkbox />
</form.Field>
</Form>
</template>);
assert.form().field("foo").hasTitle("Foo (optional)");
});
}
);

View File

@@ -44,5 +44,41 @@ module(
assert.dom(".d-editor-input").hasAttribute("disabled");
});
test("@height", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Composer @height={{42}} />
</form.Field>
</Form>
</template>);
assert
.dom(".form-kit__control-composer")
.hasAttribute("style", "height: 42px");
});
test("@preview", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Composer />
</form.Field>
</Form>
</template>);
assert.dom(".form-kit__control-composer.--preview").doesNotExist();
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Composer @preview={{true}} />
</form.Field>
</Form>
</template>);
assert.dom(".form-kit__control-composer.--preview").exists();
});
}
);

View File

@@ -124,6 +124,22 @@ module(
NO_VALUE_OPTION,
"it has the none when no value is present and field is not required"
);
await render(<template>
<Form @data={{hash foo="1"}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Select @includeNone={{false}} />
</form.Field>
</Form>
</template>);
assert
.form()
.field("foo")
.hasNoValue(
NO_VALUE_OPTION,
"it doesnt have the none for an optional field when value is present and includeNone is false"
);
});
}
);

View File

@@ -83,8 +83,8 @@ module("Integration | Component | FormKit | Field", function (hooks) {
await render(<template>
<Form as |form|>
<form.Field @name="foo.bar" @title="Foo" @size={{8}}>
Test
<form.Field @name="foo.bar" @title="Foo" @size={{8}} as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
@@ -102,8 +102,8 @@ module("Integration | Component | FormKit | Field", function (hooks) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @size={{8}}>
Test
<form.Field @name="foo" @size={{8}} as |field|>
<field.Input />
</form.Field>
</Form>
</template>);

View File

@@ -22,9 +22,41 @@ module(
</Form>
</template>);
assert.form().field("foo").hasTitle("Foo");
assert.form().field("bar").hasTitle("Bar");
assert.form().field("foo").hasTitle("Foo (optional)");
assert.form().field("bar").hasTitle("Bar (optional)");
assert.form().field("bar").hasDescription("A description");
});
test("@title", async function (assert) {
await render(<template>
<Form as |form|>
<form.CheckboxGroup @title="bar" as |checkboxGroup|>
<checkboxGroup.Field @name="foo" @title="Foo" as |field|>
<field.Checkbox />
</checkboxGroup.Field>
</form.CheckboxGroup>
</Form>
</template>);
assert
.dom(".form-kit__checkbox-group .form-kit__fieldset-title")
.hasText("bar");
});
test("@description", async function (assert) {
await render(<template>
<Form as |form|>
<form.CheckboxGroup @description="bar" as |checkboxGroup|>
<checkboxGroup.Field @name="foo" @title="Foo" as |field|>
<field.Checkbox />
</checkboxGroup.Field>
</form.CheckboxGroup>
</Form>
</template>);
assert
.dom(".form-kit__checkbox-group .form-kit__fieldset-description")
.hasText("bar");
});
}
);

View File

@@ -0,0 +1,203 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { resetRichEditorExtensions } from "discourse/lib/composer/rich-editor-extensions";
import { withPluginApi } from "discourse/lib/plugin-api";
import ProsemirrorEditor from "discourse/static/prosemirror/components/prosemirror-editor";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module("Integration | Component | prosemirror-editor", function (hooks) {
setupRenderingTest(hooks);
hooks.afterEach(function () {
resetRichEditorExtensions();
});
test("renders the editor", async function (assert) {
await render(<template><ProsemirrorEditor /></template>);
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
});
test("renders the editor with minimum extensions", async function (assert) {
const minimumExtensions = [
{ nodeSpec: { doc: { content: "inline*" }, text: { group: "inline" } } },
];
await render(<template>
<ProsemirrorEditor
@includeDefault={{false}}
@extensions={{minimumExtensions}}
/>
</template>);
assert.dom(".ProseMirror").exists("it renders the ProseMirror editor");
});
test("supports registered nodeSpec/parser/serializer", async function (assert) {
this.siteSettings.rich_editor = true;
withPluginApi("2.1.0", (api) => {
// Multiple parsers can be registered for the same node type
api.registerRichEditorExtension({
parse: { wrap_open() {}, wrap_close() {} },
});
api.registerRichEditorExtension({
nodeSpec: {
marquee: {
content: "block*",
group: "block",
parseDOM: [{ tag: "marquee" }],
toDOM: () => ["marquee", 0],
},
},
parse: {
wrap_open(state, token) {
if (token.attrGet("data-wrap") === "marquee") {
state.openNode(state.schema.nodes.marquee);
return true;
}
},
wrap_close(state) {
if (state.top().type.name === "marquee") {
state.closeNode();
return true;
}
},
},
serializeNode: {
marquee(state, node) {
state.write("[wrap=marquee]\n");
state.renderContent(node);
state.write("[/wrap]\n\n");
},
},
});
api.registerRichEditorExtension({
parse: { wrap_open() {}, wrap_close() {} },
});
});
await testMarkdown(
assert,
"[wrap=marquee]\nHello\n[wrap=marquee]\nWorld\n[/wrap]\nInner\n[/wrap]\n\nText",
"<marquee><p>Hello</p><marquee><p>World</p></marquee><p>Inner</p></marquee><p>Text</p>",
"[wrap=marquee]\nHello\n\n[wrap=marquee]\nWorld\n\n[/wrap]\n\nInner\n\n[/wrap]\n\nText"
);
});
test("supports registered markSpec/parser/serializer", async function (assert) {
this.siteSettings.rich_editor = true;
withPluginApi("2.1.0", (api) => {
api.registerRichEditorExtension({
// just for testing purpose - our actual hashtag is a node, not a mark
markSpec: {
hashtag: {
parseDOM: [{ tag: "span.hashtag-raw" }],
toDOM: () => ["span", { class: "hashtag-raw" }],
},
},
parse: {
span_open(state, token, tokens, i) {
if (token.attrGet("class") === "hashtag-raw") {
// Remove the # from the content
tokens[i + 1].content = tokens[i + 1].content.slice(1);
state.openMark(state.schema.marks.hashtag.create());
return true;
}
},
span_close(state) {
state.closeMark(state.schema.marks.hashtag);
},
},
serializeMark: { hashtag: { open: "#", close: "" } },
});
});
await testMarkdown(
assert,
"Hello #tag #test",
'<p>Hello <span class="hashtag-raw">tag</span> <span class="hashtag-raw">test</span></p>',
"Hello #tag #test"
);
});
test("supports registered nodeViews", async function (assert) {
this.siteSettings.rich_editor = true;
const state = {};
withPluginApi("2.1.0", (api) => {
api.registerRichEditorExtension({
nodeViews: {
paragraph: class CustomNodeView {
constructor() {
this.dom = document.createElement("p");
this.dom.className = "custom-p";
state.updated = true;
}
},
},
});
});
await render(<template><ProsemirrorEditor /></template>);
assert.true(
state.updated,
"it calls the update method of the custom node view"
);
assert.dom(".custom-p").exists("it renders the custom node view for p");
});
test("supports registered plugins with array, object or function", async function (assert) {
this.siteSettings.rich_editor = true;
const state = {};
withPluginApi("2.1.0", (api) => {
// plugins can be an array
api.registerRichEditorExtension({
plugins: [
{
view() {
state.plugin1 = true;
return {};
},
},
],
});
// or the plugin object itself
api.registerRichEditorExtension({
plugins: {
view() {
state.plugin2 = true;
return {};
},
},
});
// or a function that returns the plugin object
api.registerRichEditorExtension({
plugins: ({ pmState: { Plugin } }) =>
new Plugin({
view() {
state.plugin3 = true;
return {};
},
}),
});
});
await render(<template><ProsemirrorEditor /></template>);
assert.true(state.plugin1, "plugin1's view fn was called");
assert.true(state.plugin2, "plugin2's view fn was called");
assert.true(state.plugin3, "plugin3's view fn was called");
});
});

View File

@@ -0,0 +1,152 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module(
"Integration | Component | prosemirror-editor - prosemirror-markdown defaults",
function (hooks) {
setupRenderingTest(hooks);
const testCases = {
"paragraphs and hard breaks": [
["Hello", "<p>Hello</p>", "Hello"],
["Hello\nWorld", "<p>Hello<br>World</p>", "Hello\nWorld"],
["Hello\n\nWorld", "<p>Hello</p><p>World</p>", "Hello\n\nWorld"],
],
blockquotes: [
["> Hello", "<blockquote><p>Hello</p></blockquote>", "> Hello"],
[
"> Hello\n> World",
"<blockquote><p>Hello<br>World</p></blockquote>",
"> Hello\n> World",
],
[
"> Hello\n\n> World",
"<blockquote><p>Hello</p></blockquote><blockquote><p>World</p></blockquote>",
"> Hello\n\n> World",
],
],
"horizontal rule": [
[
"Hey\n\n---",
'<p>Hey</p><div contenteditable="false" draggable="true"><hr></div>',
"Hey\n\n---",
],
[
"***",
'<div contenteditable="false" draggable="true"><hr></div>',
"---",
],
],
"heading (level 1-6)": [
["# Hello", "<h1>Hello</h1>", "# Hello"],
["# Hello\nWorld", "<h1>Hello</h1><p>World</p>", "# Hello\n\nWorld"],
["## Hello", "<h2>Hello</h2>", "## Hello"],
["### Hello", "<h3>Hello</h3>", "### Hello"],
["#### Hello", "<h4>Hello</h4>", "#### Hello"],
["##### Hello", "<h5>Hello</h5>", "##### Hello"],
["###### Hello", "<h6>Hello</h6>", "###### Hello"],
],
"code block": [
["```\nHello\n```", "<pre><code>Hello</code></pre>", "```\nHello\n```"],
[
"```\nHello\nWorld\n```",
"<pre><code>Hello\nWorld</code></pre>",
"```\nHello\nWorld\n```",
],
[
"```\nHello\n\nWorld\n```",
"<pre><code>Hello\n\nWorld</code></pre>",
"```\nHello\n\nWorld\n```",
],
[
"```ruby\nHello\n```\n\nWorld",
'<pre data-params="ruby"><code>Hello</code></pre><p>World</p>',
"```ruby\nHello\n```\n\nWorld",
],
],
"ordered lists": [
[
"1. Hello",
`<ol data-tight="true"><li><p>Hello</p></li></ol>`,
"1. Hello",
],
[
"1. Hello\n2. World",
`<ol data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
"1. Hello\n2. World",
],
[
"5. Hello\n\n6. World",
`<ol start="5"><li><p>Hello</p></li><li><p>World</p></li></ol>`,
"5. Hello\n\n6. World",
],
],
"bullet lists": [
[
"* Hello",
'<ul data-tight="true"><li><p>Hello</p></li></ul>',
"* Hello",
],
[
"* Hello\n* World",
'<ul data-tight="true"><li><p>Hello</p></li><li><p>World</p></li></ul>',
"* Hello\n* World",
],
[
"* Hello\n\n* World",
"<ul><li><p>Hello</p></li><li><p>World</p></li></ul>",
"* Hello\n\n* World",
],
],
images: [
[
"![alt](src)",
'<p><img src="src" alt="alt" contenteditable="false" draggable="true"></p>',
"![alt](src)",
],
[
'![alt](src "title")',
'<p><img src="src" alt="alt" title="title" contenteditable="false" draggable="true"></p>',
'![alt](src "title")',
],
],
em: [
["*Hello*", "<p><em>Hello</em></p>", "*Hello*"],
["_Hello_", "<p><em>Hello</em></p>", "*Hello*"],
],
strong: [
["**Hello**", "<p><strong>Hello</strong></p>", "**Hello**"],
["__Hello__", "<p><strong>Hello</strong></p>", "**Hello**"],
],
link: [
["[text](href)", '<p><a href="href">text</a></p>', "[text](href)"],
[
'[text](href "title")',
'<p><a href="href" title="title">text</a></p>',
'[text](href "title")',
],
],
code: [
["Hel`lo wo`rld", "<p>Hel<code>lo wo</code>rld</p>", "Hel`lo wo`rld"],
],
"all marks": [
[
"___[`Hello`](https://example.com)___",
'<p><em><strong><a href="https://example.com"><code>Hello</code></a></strong></em></p>',
"***[`Hello`](https://example.com)***",
],
],
};
Object.entries(testCases).forEach(([name, tests]) => {
tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown);
});
});
});
}
);

View File

@@ -0,0 +1,56 @@
import { settled } from "@ember/test-helpers";
import { setupTest } from "ember-qunit";
import { applyInlineOneboxes } from "pretty-text/inline-oneboxer";
import { module, test } from "qunit";
import { ajax } from "discourse/lib/ajax";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
module("Unit | Pretty Text | Inline Oneboxer", function (hooks) {
setupTest(hooks);
let links;
hooks.beforeEach(function () {
links = {};
for (let i = 0; i < 11; i++) {
const url = `http://example.com/url-${i}`;
links[url] = document.createElement("DIV");
}
});
hooks.afterEach(function () {
links = {};
});
test("batches requests when oneboxing more than 10 urls", async function (assert) {
const requestedUrls = [];
let requestCount = 0;
pretender.get("/inline-onebox", async (request) => {
requestCount++;
requestedUrls.push(...request.queryParams.urls);
return response(200, { "inline-oneboxes": [] });
});
applyInlineOneboxes(links, ajax);
await settled();
assert.strictEqual(
requestCount,
2,
"it splits the 11 urls into 2 requests"
);
assert.deepEqual(requestedUrls, [
"http://example.com/url-0",
"http://example.com/url-1",
"http://example.com/url-2",
"http://example.com/url-3",
"http://example.com/url-4",
"http://example.com/url-5",
"http://example.com/url-6",
"http://example.com/url-7",
"http://example.com/url-8",
"http://example.com/url-9",
"http://example.com/url-10",
]);
});
});

View File

@@ -547,26 +547,6 @@ module("Unit | Utility | transformers", function (hooks) {
applyMutableValueTransformer("test-mutable-transformer", value);
assert.true(mutated, "the value is mutated");
});
test("raises an exception if the transformer returns a value different from undefined", function (assert) {
assert.throws(
() => {
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer(
"test-mutable-transformer",
() => "unexpected value"
);
});
applyMutableValueTransformer(
"test-mutable-transformer",
"default value"
);
},
/expects the value to be mutated instead of returned. Remove the return value in your transformer./,
"logs warning to the console when the transformer returns a value different from undefined"
);
});
});
module("pluginApi.addBehaviorTransformerName", function (innerHooks) {

View File

@@ -1,19 +0,0 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import AdminPlugin from "admin/models/admin-plugin";
module("Unit | Model | admin plugin", function (hooks) {
setupTest(hooks);
test("nameTitleized", function (assert) {
const adminPlugin = AdminPlugin.create({
name: "docker_manager",
});
assert.strictEqual(
adminPlugin.nameTitleized,
"Docker Manager",
"it should return titleized name replacing underscores with spaces"
);
});
});

View File

@@ -18,7 +18,7 @@
"ember-auto-import": "^2.10.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.2.0",
"ember-template-imports": "^4.3.0",
"truth-helpers": "workspace:1.0.0"
},
"devDependencies": {
@@ -45,7 +45,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -1,6 +1,6 @@
const _cache = {};
export function applyInlineOneboxes(inline, ajax, opts) {
export async function applyInlineOneboxes(inline, ajax, opts) {
opts = opts || {};
const urls = Object.keys(inline).filter((url) => !_cache[url]);
@@ -14,26 +14,35 @@ export function applyInlineOneboxes(inline, ajax, opts) {
return;
}
ajax("/inline-onebox", {
data: {
urls,
category_id: opts.categoryId,
topic_id: opts.topicId,
},
}).then((result) => {
result["inline-oneboxes"].forEach((onebox) => {
if (onebox.title) {
_cache[onebox.url] = onebox;
const batchSize = 10;
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
let links = inline[onebox.url] || [];
links.forEach((link) => {
link.innerText = onebox.title;
link.classList.add("inline-onebox");
link.classList.remove("inline-onebox-loading");
});
}
});
});
try {
const result = await ajax("/inline-onebox", {
data: {
urls: batch,
category_id: opts.categoryId,
topic_id: opts.topicId,
},
});
result["inline-oneboxes"].forEach((onebox) => {
if (onebox.title) {
_cache[onebox.url] = onebox;
let links = inline[onebox.url] || [];
links.forEach((link) => {
link.innerText = onebox.title;
link.classList.add("inline-onebox");
link.classList.remove("inline-onebox-loading");
});
}
});
} catch (err) {
// eslint-disable-next-line no-console
console.error("Inline onebox request failed", err, batch);
}
}
}
export function cachedInlineOnebox(url) {

View File

@@ -45,7 +45,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -20,7 +20,7 @@
"ember-auto-import": "^2.10.0",
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-template-imports": "^4.2.0"
"ember-template-imports": "^4.3.0"
},
"devDependencies": {
"@ember/optional-features": "^2.2.0",
@@ -46,7 +46,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "default"

View File

@@ -10,7 +10,7 @@
"@babel/standalone": "^7.26.7",
"@zxing/text-encoding": "^0.9.0",
"babel-plugin-ember-template-compilation": "^2.3.0",
"content-tag": "^3.1.0",
"content-tag": "^3.1.1",
"decorator-transforms": "^2.3.0",
"discourse": "workspace:0.0.0",
"discourse-widget-hbs": "workspace:1.0.0",
@@ -26,6 +26,6 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
}
}

View File

@@ -25,7 +25,7 @@
"node": ">= 18",
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 9"
"pnpm": "^9"
},
"ember": {
"edition": "octane"

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