Merge branch 'main' into feature/wizard-look-and-feel-improvements

This commit is contained in:
Martin Brennan 2024-12-05 09:35:48 +10:00
commit 8533290a9c
No known key found for this signature in database
GPG Key ID: BD981EFEEC8F5675
270 changed files with 4484 additions and 1684 deletions

View File

@ -119,7 +119,7 @@ GEM
css_parser (1.19.1) css_parser (1.19.1)
addressable addressable
csv (3.3.0) csv (3.3.0)
date (3.4.0) date (3.4.1)
debug_inspector (1.2.0) debug_inspector (1.2.0)
diff-lcs (1.5.1) diff-lcs (1.5.1)
diffy (3.4.3) diffy (3.4.3)
@ -161,16 +161,16 @@ GEM
fspath (3.1.2) fspath (3.1.2)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.29.0-aarch64-linux) google-protobuf (4.29.1-aarch64-linux)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.29.0-arm64-darwin) google-protobuf (4.29.1-arm64-darwin)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.29.0-x86_64-darwin) google-protobuf (4.29.1-x86_64-darwin)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.29.0-x86_64-linux) google-protobuf (4.29.1-x86_64-linux)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
guess_html_encoding (0.0.11) guess_html_encoding (0.0.11)
@ -197,9 +197,10 @@ GEM
reline (>= 0.4.2) reline (>= 0.4.2)
iso8601 (0.13.0) iso8601 (0.13.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.8.2) json (2.9.0)
json-schema (5.1.0) json-schema (5.1.1)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (~> 3.1)
json_schemer (2.3.0) json_schemer (2.3.0)
bigdecimal bigdecimal
hana (~> 1.3) hana (~> 1.3)
@ -217,7 +218,7 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
literate_randomizer (0.4.0) literate_randomizer (0.4.0)
logger (1.6.1) logger (1.6.2)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
@ -252,7 +253,7 @@ GEM
mini_suffix (0.3.3) mini_suffix (0.3.3)
ffi (~> 1.9) ffi (~> 1.9)
minio_runner (0.1.2) minio_runner (0.1.2)
minitest (5.25.2) minitest (5.25.4)
mocha (2.6.1) mocha (2.6.1)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
msgpack (1.7.5) msgpack (1.7.5)
@ -260,7 +261,7 @@ GEM
multi_xml (0.7.1) multi_xml (0.7.1)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
mustache (1.1.1) mustache (1.1.1)
net-http (0.5.0) net-http (0.6.0)
uri uri
net-imap (0.5.1) net-imap (0.5.1)
date date
@ -272,13 +273,13 @@ GEM
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.16.7-aarch64-linux) nokogiri (1.16.8-aarch64-linux)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin) nokogiri (1.16.8-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin) nokogiri (1.16.8-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux) nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@ -343,7 +344,8 @@ GEM
pry-stack_explorer (0.6.1) pry-stack_explorer (0.6.1)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
pry (~> 0.13) pry (~> 0.13)
psych (5.2.0) psych (5.2.1)
date
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.5.0) puma (6.5.0)
@ -366,9 +368,9 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_failover (2.1.1) rails_failover (2.1.1)
activerecord (>= 6.1, < 8.0) activerecord (>= 6.1, < 8.0)
concurrent-ruby concurrent-ruby
@ -441,7 +443,7 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.1) rspec-support (3.13.2)
rss (0.3.1) rss (0.3.1)
rexml rexml
rswag-specs (2.16.0) rswag-specs (2.16.0)
@ -451,21 +453,21 @@ GEM
rspec-core (>= 2.14) rspec-core (>= 2.14)
rtlcss (0.2.1) rtlcss (0.2.1)
mini_racer (>= 0.6.3) mini_racer (>= 0.6.3)
rubocop (1.69.0) rubocop (1.69.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.1, < 2.0) rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.36.2) rubocop-ast (1.36.2)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-capybara (2.21.0) rubocop-capybara (2.21.0)
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-discourse (3.8.6) rubocop-discourse (3.9.0)
activesupport (>= 6.1) activesupport (>= 6.1)
rubocop (>= 1.59.0) rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0) rubocop-capybara (>= 2.0.0)
@ -505,7 +507,7 @@ GEM
google-protobuf (>= 3.25, < 5.0) google-protobuf (>= 3.25, < 5.0)
sassc-embedded (1.77.7) sassc-embedded (1.77.7)
sass-embedded (~> 1.77) sass-embedded (~> 1.77)
securerandom (0.3.2) securerandom (0.4.0)
selenium-devtools (0.131.0) selenium-devtools (0.131.0)
selenium-webdriver (~> 4.2) selenium-webdriver (~> 4.2)
selenium-webdriver (4.27.0) selenium-webdriver (4.27.0)
@ -538,10 +540,10 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.3.1-aarch64-linux-gnu) sqlite3 (2.4.0-aarch64-linux-gnu)
sqlite3 (2.3.1-arm64-darwin) sqlite3 (2.4.0-arm64-darwin)
sqlite3 (2.3.1-x86_64-darwin) sqlite3 (2.4.0-x86_64-darwin)
sqlite3 (2.3.1-x86_64-linux-gnu) sqlite3 (2.4.0-x86_64-linux-gnu)
sshkey (3.0.0) sshkey (3.0.0)
stackprof (0.2.26) stackprof (0.2.26)
stringio (3.1.2) stringio (3.1.2)
@ -567,7 +569,7 @@ GEM
raindrops (~> 0.7) raindrops (~> 0.7)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (1.0.2) uri (1.0.2)
useragent (0.16.10) useragent (0.16.11)
version_gem (1.1.4) version_gem (1.1.4)
web-push (3.0.1) web-push (3.0.1)
jwt (~> 2.0) jwt (~> 2.0)
@ -576,7 +578,7 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.0) webrick (1.9.1)
websocket (1.2.11) websocket (1.2.11)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)

View File

@ -27,6 +27,7 @@ const FORM_FIELDS = [
"badge_grouping_id", "badge_grouping_id",
"trigger", "trigger",
"badge_type_id", "badge_type_id",
"show_in_post_header",
]; ];
export default class AdminBadgesShowController extends Controller { export default class AdminBadgesShowController extends Controller {
@ -40,8 +41,6 @@ export default class AdminBadgesShowController extends Controller {
@tracked model; @tracked model;
@tracked previewLoading = false; @tracked previewLoading = false;
@tracked selectedGraphicType = null; @tracked selectedGraphicType = null;
@tracked userBadges;
@tracked userBadgesAll;
@cached @cached
get formData() { get formData() {
@ -80,6 +79,17 @@ export default class AdminBadgesShowController extends Controller {
return this.model.system; return this.model.system;
} }
@action
postHeaderDescription(data) {
return this.disableBadgeOnPosts(data) && !data.system;
}
@action
disableBadgeOnPosts(data) {
const { listable, show_posts } = data;
return !listable || !show_posts;
}
setup() { setup() {
// this is needed because the model doesnt have default values // this is needed because the model doesnt have default values
// Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet. // Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet.

View File

@ -245,7 +245,7 @@
</field.Menu> </field.Menu>
</form.Field> </form.Field>
<form.CheckboxGroup as |group|> <form.CheckboxGroup @title={{i18n "admin.badges.usage_heading"}} as |group|>
<group.Field <group.Field
@title={{i18n "admin.badges.allow_title"}} @title={{i18n "admin.badges.allow_title"}}
@showTitle={{false}} @showTitle={{false}}
@ -264,7 +264,12 @@
> >
<field.Checkbox /> <field.Checkbox />
</group.Field> </group.Field>
</form.CheckboxGroup>
<form.CheckboxGroup
@title={{i18n "admin.badges.visibility_heading"}}
as |group|
>
<group.Field <group.Field
@title={{i18n "admin.badges.listable"}} @title={{i18n "admin.badges.listable"}}
@showTitle={{false}} @showTitle={{false}}
@ -284,6 +289,20 @@
> >
<field.Checkbox /> <field.Checkbox />
</group.Field> </group.Field>
<group.Field
@title={{i18n "admin.badges.show_in_post_header"}}
@showTitle={{false}}
@name="show_in_post_header"
@disabled={{this.disableBadgeOnPosts data}}
as |field|
>
<field.Checkbox>
{{#if (this.postHeaderDescription data)}}
{{i18n "admin.badges.show_in_post_header_disabled"}}
{{/if}}
</field.Checkbox>
</group.Field>
</form.CheckboxGroup> </form.CheckboxGroup>
</form.Section> </form.Section>

View File

@ -20,9 +20,10 @@
<PluginOutlet @name="admin-users-list-show-before" /> <PluginOutlet @name="admin-users-list-show-before" />
<div class="admin-users-list__controls"> <div class="d-admin-filter admin-users-list__controls">
<div class="admin-users-list__search"> <div class="admin-filter__input-container admin-users-list__search">
<input <input
class="admin-filter__input"
type="text" type="text"
dir="auto" dir="auto"
placeholder={{this.searchHint}} placeholder={{this.searchHint}}

View File

@ -39,7 +39,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -19,7 +19,7 @@
"@types/jquery": "^3.5.32", "@types/jquery": "^3.5.32",
"@types/qunit": "^2.19.12", "@types/qunit": "^2.19.12",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -4,10 +4,6 @@ const DEPRECATION_WORKFLOW = [
handler: "silence", handler: "silence",
matchId: "discourse.decorate-widget.hamburger-widget-links", matchId: "discourse.decorate-widget.hamburger-widget-links",
}, },
{
handler: "silence",
matchId: "discourse.fontawesome-6-upgrade",
},
{ {
handler: "silence", handler: "silence",
matchId: "discourse.post-menu-widget-overrides", matchId: "discourse.post-menu-widget-overrides",

View File

@ -41,7 +41,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -187,7 +187,7 @@ function renderImageOrPlayableMedia(tokens, idx, options, env, slf) {
options.discourse.previewing && options.discourse.previewing &&
!options.discourse.limitedSiteSettings.enableDiffhtmlPreview !options.discourse.limitedSiteSettings.enableDiffhtmlPreview
) { ) {
const origSrc = token.attrGet("data-orig-src"); const origSrc = token.attrGet("data-orig-src") || token.attrGet("src");
const origSrcId = origSrc const origSrcId = origSrc
.substring(origSrc.lastIndexOf("/") + 1) .substring(origSrc.lastIndexOf("/") + 1)
.split(".")[0]; .split(".")[0];

View File

@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"ember-cli": "~6.0.1", "ember-cli": "~6.0.1",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -24,7 +24,7 @@
"@ember/optional-features": "^2.2.0", "@ember/optional-features": "^2.2.0",
"@embroider/test-setup": "^4.0.0", "@embroider/test-setup": "^4.0.0",
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/syntax": "^0.92.3", "@glimmer/syntax": "^0.93.1",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-cli": "~6.0.1", "ember-cli": "~6.0.1",
"ember-cli-inject-live-reload": "^2.1.0", "ember-cli-inject-live-reload": "^2.1.0",
@ -36,7 +36,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -215,7 +215,7 @@ export default class AboutPage extends Component {
{{#if this.currentUser.admin}} {{#if this.currentUser.admin}}
<p> <p>
<LinkTo class="edit-about-page" @route="adminConfig.about"> <LinkTo class="edit-about-page" @route="adminConfig.about">
{{dIcon "pencil-alt"}} {{dIcon "pencil"}}
<span>{{i18n "about.edit"}}</span> <span>{{i18n "about.edit"}}</span>
</LinkTo> </LinkTo>
</p> </p>

View File

@ -10,6 +10,10 @@ export default class BadgeButton extends Component {
} }
} }
get showName() {
return this.args.showName ?? true;
}
<template> <template>
<span <span
title={{this.title}} title={{this.title}}
@ -20,7 +24,9 @@ export default class BadgeButton extends Component {
...attributes ...attributes
> >
{{iconOrImage @badge}} {{iconOrImage @badge}}
{{#if this.showName}}
<span class="badge-display-name">{{@badge.name}}</span> <span class="badge-display-name">{{@badge.name}}</span>
{{/if}}
{{yield}} {{yield}}
</span> </span>
</template> </template>

View File

@ -1,5 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { tagName } from "@ember-decorators/component"; import { tagName } from "@ember-decorators/component";
import { applyValueTransformer } from "discourse/lib/transformer";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
const LIST_TYPE = { const LIST_TYPE = {
@ -40,4 +41,8 @@ export default class CategoryListItem extends Component {
slugPath(categoryPath) { slugPath(categoryPath) {
return categoryPath.substring("/c/".length); return categoryPath.substring("/c/".length);
} }
applyValueTransformer(name, value, context) {
return applyValueTransformer(name, value, context);
}
} }

View File

@ -43,6 +43,7 @@
@outletArgs={{hash composer=this.composer.model editorType="composer"}} @outletArgs={{hash composer=this.composer.model editorType="composer"}}
@topicId={{this.composer.model.topic.id}} @topicId={{this.composer.model.topic.id}}
@categoryId={{this.composer.model.category.id}} @categoryId={{this.composer.model.category.id}}
@onSetup={{this.setupEditor}}
> >
{{yield}} {{yield}}
</DEditor> </DEditor>

View File

@ -18,7 +18,6 @@ import {
linkSeenMentions, linkSeenMentions,
} from "discourse/lib/link-mentions"; } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { loadOneboxes } from "discourse/lib/load-oneboxes";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import { import {
authorizesOneOrMoreImageExtensions, authorizesOneOrMoreImageExtensions,
IMAGE_MARKDOWN_REGEX, IMAGE_MARKDOWN_REGEX,
@ -139,7 +138,7 @@ export default class ComposerEditor extends Component {
@observes("composer.focusTarget") @observes("composer.focusTarget")
setFocus() { setFocus() {
if (this.composer.focusTarget === "editor") { if (this.composer.focusTarget === "editor") {
putCursorAtEnd(this.element.querySelector("textarea")); this.textManipulation.putCursorAtEnd();
} }
} }
@ -188,21 +187,9 @@ export default class ComposerEditor extends Component {
@on("didInsertElement") @on("didInsertElement")
_composerEditorInit() { _composerEditorInit() {
const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper"); const preview = this.element.querySelector(".d-editor-preview-wrapper");
input?.addEventListener(
"scroll",
this._throttledSyncEditorAndPreviewScroll
);
this._registerImageAltTextButtonClick(preview); this._registerImageAltTextButtonClick(preview);
// Focus on the body unless we have a title
if (!this.get("composer.model.canEditTitle")) {
putCursorAtEnd(input);
}
if (this.composer.allowUpload) { if (this.composer.allowUpload) {
this.uppyComposerUpload.setup(this.element); this.uppyComposerUpload.setup(this.element);
} }
@ -210,6 +197,30 @@ export default class ComposerEditor extends Component {
this.appEvents.trigger(`${this.composerEventPrefix}:will-open`); this.appEvents.trigger(`${this.composerEventPrefix}:will-open`);
} }
@bind
setupEditor(textManipulation) {
this.textManipulation = textManipulation;
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.textManipulation.putCursorAtEnd();
}
return () => {
input?.removeEventListener(
"scroll",
this._throttledSyncEditorAndPreviewScroll
);
};
}
@discourseComputed( @discourseComputed(
"composer.model.reply", "composer.model.reply",
"composer.model.replyLength", "composer.model.replyLength",
@ -785,7 +796,6 @@ export default class ComposerEditor extends Component {
@on("willDestroyElement") @on("willDestroyElement")
_composerClosed() { _composerClosed() {
const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper"); const preview = this.element.querySelector(".d-editor-preview-wrapper");
if (this.composer.allowUpload) { if (this.composer.allowUpload) {
@ -802,11 +812,6 @@ export default class ComposerEditor extends Component {
); );
}); });
input?.removeEventListener(
"scroll",
this._throttledSyncEditorAndPreviewScroll
);
preview?.removeEventListener("click", this._handleAltTextCancelButtonClick); preview?.removeEventListener("click", this._handleAltTextCancelButtonClick);
preview?.removeEventListener("click", this._handleAltTextEditButtonClick); preview?.removeEventListener("click", this._handleAltTextEditButtonClick);
preview?.removeEventListener("click", this._handleAltTextOkButtonClick); preview?.removeEventListener("click", this._handleAltTextOkButtonClick);

View File

@ -21,7 +21,6 @@ import { PLATFORM_KEY_MODIFIER } from "discourse/lib/keyboard-shortcuts";
import { linkSeenMentions } from "discourse/lib/link-mentions"; import { linkSeenMentions } from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes"; import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text"; import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { getHead } from "discourse/lib/textarea-text-manipulation";
import userSearch from "discourse/lib/user-search"; import userSearch from "discourse/lib/user-search";
import { import {
destroyUserStatuses, destroyUserStatuses,
@ -36,8 +35,6 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
import discourseComputed, { bind } from "discourse-common/utils/decorators"; import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
const FOUR_SPACES_INDENT = "4-spaces-indent";
let _createCallbacks = []; let _createCallbacks = [];
export function addToolbarCallback(func) { export function addToolbarCallback(func) {
@ -145,8 +142,6 @@ export default class DEditor extends Component {
keymap["tab"] = () => this.textManipulation.indentSelection("right"); keymap["tab"] = () => this.textManipulation.indentSelection("right");
keymap["shift+tab"] = () => this.textManipulation.indentSelection("left"); keymap["shift+tab"] = () => this.textManipulation.indentSelection("left");
keymap[`${PLATFORM_KEY_MODIFIER}+shift+.`] = () =>
this.send("insertCurrentTime");
return keymap; return keymap;
} }
@ -485,34 +480,6 @@ export default class DEditor extends Component {
}); });
} }
_applyList(sel, head, exampleKey, opts) {
if (sel.value.includes("\n")) {
this.textManipulation.applySurround(sel, head, "", exampleKey, opts);
} else {
const [hval, hlen] = getHead(head);
if (sel.start === sel.end) {
sel.value = i18n(`composer.${exampleKey}`);
}
const trimmedPre = sel.pre.trim();
const number = sel.value.startsWith(hval)
? sel.value.slice(hlen)
: `${hval}${sel.value}`;
const preLines = trimmedPre.length ? `${trimmedPre}\n\n` : "";
const trimmedPost = sel.post.trim();
const post = trimmedPost.length ? `\n\n${trimmedPost}` : trimmedPost;
this.set("value", `${preLines}${number}${post}`);
this.textManipulation.selectText(preLines.length, number.length);
}
}
_applySurround(head, tail, exampleKey, opts) {
const selected = this.textManipulation.getSelected();
this.textManipulation.applySurround(selected, head, tail, exampleKey, opts);
}
@action @action
rovingButtonBar(event) { rovingButtonBar(event) {
let target = event.target; let target = event.target;
@ -559,6 +526,27 @@ export default class DEditor extends Component {
} }
} }
/**
* Represents a toolbar event object passed to toolbar buttons.
*
* @typedef {Object} ToolbarEvent
* @property {function} applySurround - Applies surrounding text
* @property {function} formatCode - Formats as code
* @property {function} replaceText - Replaces text
* @property {function} selectText - Selects a range of text
* @property {function} toggleDirection - Toggles text direction
* @property {function} getText - Gets the text
* @property {function} addText - Adds text
* @property {function} applyList - Applies a list format
* @property {*} selected - The current selection
*/
/**
* Creates a new toolbar event object
*
* @param {boolean} trimLeading - Whether to trim leading whitespace
* @returns {ToolbarEvent} An object with toolbar event actions
*/
newToolbarEvent(trimLeading) { newToolbarEvent(trimLeading) {
const selected = this.textManipulation.getSelected(trimLeading); const selected = this.textManipulation.getSelected(trimLeading);
return { return {
@ -574,8 +562,8 @@ export default class DEditor extends Component {
opts opts
), ),
applyList: (head, exampleKey, opts) => applyList: (head, exampleKey, opts) =>
this._applyList(selected, head, exampleKey, opts), this.textManipulation.applyList(selected, head, exampleKey, opts),
formatCode: (...args) => this.send("formatCode", args), formatCode: () => this.textManipulation.formatCode(),
addText: (text) => this.textManipulation.addText(selected, text), addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value, getText: () => this.value,
toggleDirection: () => this.textManipulation.toggleDirection(), toggleDirection: () => this.textManipulation.toggleDirection(),
@ -628,71 +616,6 @@ export default class DEditor extends Component {
}); });
} }
@action
formatCode() {
if (this.disabled) {
return;
}
const sel = this.textManipulation.getSelected("", { lineVal: true });
const selValue = sel.value;
const hasNewLine = selValue.includes("\n");
const isBlankLine = sel.lineVal.trim().length === 0;
const isFourSpacesIndent =
this.siteSettings.code_formatting_style === FOUR_SPACES_INDENT;
if (!hasNewLine) {
if (selValue.length === 0 && isBlankLine) {
if (isFourSpacesIndent) {
const example = i18n(`composer.code_text`);
this.set("value", `${sel.pre} ${example}${sel.post}`);
return this.textManipulation.selectText(
sel.pre.length + 4,
example.length
);
} else {
return this.textManipulation.applySurround(
sel,
"```\n",
"\n```",
"paste_code_text"
);
}
} else {
return this.textManipulation.applySurround(sel, "`", "`", "code_title");
}
} else {
if (isFourSpacesIndent) {
return this.textManipulation.applySurround(
sel,
" ",
"",
"code_text"
);
} else {
const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : "";
const postNewline = sel.post[0] !== "\n" ? "\n" : "";
return this.textManipulation.addText(
sel,
`${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}`
);
}
}
}
@action
insertCurrentTime() {
const sel = this.textManipulation.getSelected("", { lineVal: true });
const timezone = this.currentUser.user_option.timezone;
const time = moment().format("HH:mm:ss");
const date = moment().format("YYYY-MM-DD");
this.textManipulation.addText(
sel,
`[date=${date} time=${time} timezone="${timezone}"]`
);
}
@action @action
handleFocusIn() { handleFocusIn() {
this.set("isEditorFocused", true); this.set("isEditorFocused", true);
@ -715,6 +638,8 @@ export default class DEditor extends Component {
this._applyHashtagAutocomplete(); this._applyHashtagAutocomplete();
this._applyMentionAutocomplete(); this._applyMentionAutocomplete();
const destroyEditor = this.onSetup?.(textManipulation);
scheduleOnce("afterRender", this, this._readyNow); scheduleOnce("afterRender", this, this._readyNow);
return () => { return () => {
@ -723,6 +648,8 @@ export default class DEditor extends Component {
this.element?.removeEventListener("paste", textManipulation.paste); this.element?.removeEventListener("paste", textManipulation.paste);
textManipulation.autocomplete("destroy"); textManipulation.autocomplete("destroy");
destroyEditor?.();
}; };
} }
@ -741,7 +668,11 @@ export default class DEditor extends Component {
textManipulation, textManipulation,
"replaceText" "replaceText"
); );
this.appEvents.on("composer:apply-surround", this, "_applySurround"); this.appEvents.on(
"composer:apply-surround",
textManipulation,
"applySurroundSelection"
);
this.appEvents.on( this.appEvents.on(
"composer:indent-selected-text", "composer:indent-selected-text",
textManipulation, textManipulation,
@ -764,7 +695,11 @@ export default class DEditor extends Component {
textManipulation, textManipulation,
"replaceText" "replaceText"
); );
this.appEvents.off("composer:apply-surround", this, "_applySurround"); this.appEvents.off(
"composer:apply-surround",
textManipulation,
"applySurroundSelection"
);
this.appEvents.off( this.appEvents.off(
"composer:indent-selected-text", "composer:indent-selected-text",
textManipulation, textManipulation,

View File

@ -12,6 +12,7 @@ import DButton from "discourse/components/d-button";
import FlashMessage from "discourse/components/flash-message"; import FlashMessage from "discourse/components/flash-message";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import element from "discourse/helpers/element"; import element from "discourse/helpers/element";
import htmlClass from "discourse/helpers/html-class";
import { import {
disableBodyScroll, disableBodyScroll,
enableBodyScroll, enableBodyScroll,
@ -261,6 +262,7 @@ export default class DModal extends Component {
@inline={{@inline}} @inline={{@inline}}
@append={{true}} @append={{true}}
> >
{{htmlClass "modal-open"}}
<this.dynamicElement <this.dynamicElement
class={{concatClass class={{concatClass
"modal" "modal"

View File

@ -5,6 +5,14 @@
/> />
{{#if this.site.mobileView}} {{#if this.site.mobileView}}
<PluginOutlet
@name="category-list-before-category-mobile"
@outletArgs={{hash
category=this.category
listType=this.listType
isMuted=this.isMuted
}}
/>
<div <div
data-category-id={{this.category.id}} data-category-id={{this.category.id}}
data-notification-level={{this.category.notificationLevelString}} data-notification-level={{this.category.notificationLevelString}}
@ -80,8 +88,19 @@
'has-description' 'has-description'
'no-description' 'no-description'
}} }}
{{this.applyValueTransformer
'parent-category-row-class'
(array)
(hash category=this.category)
}}
{{if this.category.uploaded_logo.url 'has-logo' 'no-logo'}}" {{if this.category.uploaded_logo.url 'has-logo' 'no-logo'}}"
> >
<PluginOutlet
@name="category-list-before-category-section"
@outletArgs={{hash category=this.category listType=this.listType}}
/>
<td <td
class="category {{if this.isMuted 'muted'}}" class="category {{if this.isMuted 'muted'}}"
style={{category-color-variable this.category.color}} style={{category-color-variable this.category.color}}
@ -141,6 +160,11 @@
{{/if}} {{/if}}
</td> </td>
<PluginOutlet
@name="category-list-before-topics-section"
@outletArgs={{hash category=this.category}}
/>
<td class="topics"> <td class="topics">
<div title={{this.category.statTitle}}>{{html-safe <div title={{this.category.statTitle}}>{{html-safe
this.category.stat this.category.stat
@ -154,6 +178,11 @@
/> />
</td> </td>
<PluginOutlet
@name="category-list-after-topics-section"
@outletArgs={{hash category=this.category}}
/>
{{#unless this.isMuted}} {{#unless this.isMuted}}
{{#if this.showTopics}} {{#if this.showTopics}}
<td class="latest"> <td class="latest">
@ -165,6 +194,10 @@
{{/if}} {{/if}}
{{/each}} {{/each}}
</td> </td>
<PluginOutlet
@name="category-list-after-latest-section"
@outletArgs={{hash category=this.category}}
/>
{{/if}} {{/if}}
{{/unless}} {{/unless}}
</tr> </tr>

View File

@ -35,7 +35,7 @@ export default class PostMenuEditButton extends Component {
}} }}
...attributes ...attributes
@action={{@buttonActions.editPost}} @action={{@buttonActions.editPost}}
@icon={{if @post.wiki "far-edit" "pencil-alt"}} @icon={{if @post.wiki "far-edit" "pencil"}}
@label={{if this.showLabel "post.controls.edit_action"}} @label={{if this.showLabel "post.controls.edit_action"}}
@title="post.controls.edit" @title="post.controls.edit"
/> />

View File

@ -1,11 +1,18 @@
<div class="author"> <PluginOutlet
@name="search-results-topic-avatar-wrapper"
@outletArgs={{hash post=this.post}}
>
<div class="author">
<a href={{this.post.userPath}} data-user-card={{this.post.username}}> <a href={{this.post.userPath}} data-user-card={{this.post.username}}>
{{avatar this.post imageSize="large"}} {{avatar this.post imageSize="large"}}
</a> </a>
</div> </div>
</PluginOutlet>
<div class="fps-topic" data-topic-id={{this.post.topic.id}}> <div class="fps-topic" data-topic-id={{this.post.topic.id}}>
<div class="topic"> <div class="topic">
{{#if this.bulkSelectEnabled}} {{#if this.bulkSelectEnabled}}
<TrackSelected <TrackSelected
@selectedList={{this.selected}} @selectedList={{this.selected}}
@ -55,6 +62,10 @@
</div> </div>
</div> </div>
<PluginOutlet
@name="search-result-entry-blurb-wrapper"
@outletArgs={{hash post=this.post logClick=this.logClick}}
>
<div class="blurb container"> <div class="blurb container">
<span class="date"> <span class="date">
{{format-date this.post.created_at format="tiny"}} {{format-date this.post.created_at format="tiny"}}
@ -73,7 +84,12 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
</div> </div>
</PluginOutlet>
<PluginOutlet
@name="search-result-entry-stats-wrapper"
@outletArgs={{hash post=this.post}}
>
{{#if this.showLikeCount}} {{#if this.showLikeCount}}
{{#if this.post.like_count}} {{#if this.post.like_count}}
<span class="like-count"> <span class="like-count">
@ -82,6 +98,7 @@
</span> </span>
{{/if}} {{/if}}
{{/if}} {{/if}}
</PluginOutlet>
</div> </div>
<PluginOutlet @name="after-search-result-entry" /> <PluginOutlet @name="after-search-result-entry" />

View File

@ -1,5 +1,5 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat, hash } from "@ember/helper"; import { array, concat, hash } from "@ember/helper";
import { on } from "@ember/modifier"; import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
@ -20,6 +20,7 @@ import discourseTags from "discourse/helpers/discourse-tags";
import formatDate from "discourse/helpers/format-date"; import formatDate from "discourse/helpers/format-date";
import topicFeaturedLink from "discourse/helpers/topic-featured-link"; import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { wantsNewWindow } from "discourse/lib/intercept-click"; import { wantsNewWindow } from "discourse/lib/intercept-click";
import { applyValueTransformer } from "discourse/lib/transformer";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -208,6 +209,9 @@ export default class Item extends Component {
(if @topic.pinned "pinned") (if @topic.pinned "pinned")
(if @topic.closed "closed") (if @topic.closed "closed")
this.tagClassNames this.tagClassNames
(applyValueTransformer
"topic-list-item-class" (array) (hash topic=@topic)
)
}} }}
> >
<PluginOutlet <PluginOutlet

View File

@ -15,7 +15,7 @@ export default class UserBadge extends Component {
<template> <template>
<a class="user-card-badge-link" href={{this.badgeUrl}}> <a class="user-card-badge-link" href={{this.badgeUrl}}>
<BadgeButton @badge={{@badge}}> <BadgeButton @badge={{@badge}} @showName={{@showName}}>
{{#if this.showGrantCount}} {{#if this.showGrantCount}}
<span class="count">&nbsp;(&times;{{@count}})</span> <span class="count">&nbsp;(&times;{{@count}})</span>
{{/if}} {{/if}}

View File

@ -848,12 +848,7 @@ export default class TopicController extends Controller.extend(
return false; return false;
} }
const composer = this.composer; const topic = this.model;
let topic = this.model;
const composerModel = composer.get("model");
let editingFirst =
composerModel &&
(post.get("firstPost") || composerModel.get("editingFirstPost"));
let editingSharedDraft = false; let editingSharedDraft = false;
let draftsCategoryId = this.get("site.shared_drafts_category_id"); let draftsCategoryId = this.get("site.shared_drafts_category_id");
@ -872,22 +867,14 @@ export default class TopicController extends Controller.extend(
opts.destinationCategoryId = topic.get("destination_category_id"); opts.destinationCategoryId = topic.get("destination_category_id");
} }
// Reopen the composer if we're editing the same post const { composer } = this;
const editingExisting = const composerModel = composer.get("model");
post.id === composerModel?.post?.id && const editingSamePost =
opts?.action === Composer.EDIT && opts.post.id === composerModel?.post?.id &&
composerModel?.draftKey === opts.draftKey; opts.action === composerModel?.action &&
if (editingExisting) { opts.draftKey === composerModel?.draftKey;
composer.unshrink();
return;
}
// Cancel and reopen the composer for the first post return editingSamePost ? composer.unshrink() : composer.open(opts);
if (editingFirst) {
composer.cancelComposer(opts).then(() => composer.open(opts));
} else {
composer.open(opts);
}
} }
@action @action

View File

@ -78,7 +78,7 @@ export default class Toolbar {
icon: "code", icon: "code",
preventFocus: true, preventFocus: true,
trimLeading: true, trimLeading: true,
action: (...args) => this.context.send("formatCode", args), perform: (e) => e.formatCode(),
}); });
this.addButton({ this.addButton({

View File

@ -3,6 +3,7 @@ import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import $ from "jquery"; import $ from "jquery";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import { generateLinkifyFunction } from "discourse/lib/text"; import { generateLinkifyFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction"; import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown"; import toMarkdown from "discourse/lib/to-markdown";
@ -28,6 +29,8 @@ const OP = {
ADDED: 2, ADDED: 2,
}; };
const FOUR_SPACES_INDENT = "4-spaces-indent";
// Our head can be a static string or a function that returns a string // Our head can be a static string or a function that returns a string
// based on input (like for numbered lists). // based on input (like for numbered lists).
export function getHead(head, prev) { export function getHead(head, prev) {
@ -42,6 +45,7 @@ export default class TextareaTextManipulation {
@service appEvents; @service appEvents;
@service siteSettings; @service siteSettings;
@service capabilities; @service capabilities;
@service currentUser;
eventPrefix; eventPrefix;
textarea; textarea;
@ -180,6 +184,10 @@ export default class TextareaTextManipulation {
} }
} }
applySurroundSelection(head, tail, exampleKey, opts) {
this.applySurround(this.getSelected(), head, tail, exampleKey, opts);
}
applySurround(sel, head, tail, exampleKey, opts) { applySurround(sel, head, tail, exampleKey, opts) {
const pre = sel.pre; const pre = sel.pre;
const post = sel.post; const post = sel.post;
@ -730,6 +738,7 @@ export default class TextareaTextManipulation {
} }
} }
@bind
async inCodeBlock() { async inCodeBlock() {
return inCodeBlock( return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(), this.$textarea.value ?? this.$textarea.val(),
@ -737,6 +746,7 @@ export default class TextareaTextManipulation {
); );
} }
@bind
toggleDirection() { toggleDirection() {
let currentDir = this.$textarea.attr("dir") let currentDir = this.$textarea.attr("dir")
? this.$textarea.attr("dir") ? this.$textarea.attr("dir")
@ -746,6 +756,75 @@ export default class TextareaTextManipulation {
this.$textarea.attr("dir", newDir).focus(); this.$textarea.attr("dir", newDir).focus();
} }
@bind
applyList(sel, head, exampleKey, opts) {
if (sel.value.includes("\n")) {
this.applySurround(sel, head, "", exampleKey, opts);
} else {
const [hval, hlen] = getHead(head);
if (sel.start === sel.end) {
sel.value = i18n(`composer.${exampleKey}`);
}
const number = sel.value.startsWith(hval)
? sel.value.slice(hlen)
: `${hval}${sel.value}`;
const preNewlines = sel.pre.trim() && "\n\n";
const postNewlines = sel.post.trim() && "\n\n";
const textToInsert = `${preNewlines}${number}${postNewlines}`;
const preChars = sel.pre.length - sel.pre.trimEnd().length;
const postChars = sel.post.length - sel.post.trimStart().length;
this._insertAt(sel.start - preChars, sel.end + postChars, textToInsert);
this.selectText(
sel.start + (preNewlines.length - preChars),
number.length
);
}
}
@bind
formatCode() {
const sel = this.getSelected("", { lineVal: true });
const selValue = sel.value;
const hasNewLine = selValue.includes("\n");
const isBlankLine = sel.lineVal.trim().length === 0;
const isFourSpacesIndent =
this.siteSettings.code_formatting_style === FOUR_SPACES_INDENT;
if (!hasNewLine) {
if (selValue.length === 0 && isBlankLine) {
if (isFourSpacesIndent) {
const example = i18n(`composer.code_text`);
this._insertAt(sel.start, sel.end, ` ${example}`);
return this.selectText(sel.pre.length + 4, example.length);
} else {
return this.applySurround(sel, "```\n", "\n```", "paste_code_text");
}
} else {
return this.applySurround(sel, "`", "`", "code_title");
}
} else {
if (isFourSpacesIndent) {
return this.applySurround(sel, " ", "", "code_text");
} else {
const preNewline = sel.pre[-1] !== "\n" && sel.pre !== "" ? "\n" : "";
const postNewline = sel.post[0] !== "\n" ? "\n" : "";
return this.addText(
sel,
`${preNewline}\`\`\`\n${sel.value}\n\`\`\`${postNewline}`
);
}
}
}
putCursorAtEnd() {
putCursorAtEnd(this.textarea);
}
autocomplete() { autocomplete() {
return this.$textarea.autocomplete(...arguments); return this.$textarea.autocomplete(...arguments);
} }

View File

@ -1,5 +1,6 @@
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
import Badge from "discourse/models/badge";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -37,6 +38,9 @@ export function transformBasicPost(post) {
user_id: post.user_id, user_id: post.user_id,
usernameUrl: userPath(post.username), usernameUrl: userPath(post.username),
username: post.username, username: post.username,
badgesGranted: post.badges_granted?.map(
(badge) => Badge.createFromJson(badge)[0]
),
avatar_template: post.avatar_template, avatar_template: post.avatar_template,
bookmarked: post.bookmarked, bookmarked: post.bookmarked,
bookmarkReminderAt: post.bookmark_reminder_at, bookmarkReminderAt: post.bookmark_reminder_at,

View File

@ -13,8 +13,11 @@ export const VALUE_TRANSFORMERS = Object.freeze([
"invite-simple-mode-topic", "invite-simple-mode-topic",
"mentions-class", "mentions-class",
"more-topics-tabs", "more-topics-tabs",
"parent-category-row-class-mobile",
"parent-category-row-class",
"post-menu-buttons", "post-menu-buttons",
"small-user-attrs", "small-user-attrs",
"topic-list-columns", "topic-list-columns",
"topic-list-header-sortable-column", "topic-list-header-sortable-column",
"topic-list-item-class",
]); ]);

View File

@ -156,16 +156,20 @@ export function selectedText() {
} else if (oneboxTest) { } else if (oneboxTest) {
// This is a partial quote from a onebox. // This is a partial quote from a onebox.
// Treat it as though the entire onebox was quoted. // Treat it as though the entire onebox was quoted.
const oneboxUrl = oneboxTest.dataset.oneboxSrc; div.append(oneboxTest.dataset.oneboxSrc);
div.append(oneboxUrl);
} else { } else {
div.append(range.cloneContents()); div.append(range.cloneContents());
} }
} }
div.querySelectorAll("aside.onebox[data-onebox-src]").forEach((element) => { div.querySelectorAll("aside.onebox[data-onebox-src]").forEach((element) => {
const oneboxUrl = element.dataset.oneboxSrc; element.replaceWith(element.dataset.oneboxSrc);
element.replaceWith(oneboxUrl); });
div
.querySelectorAll("div.video-placeholder-container[data-video-src]")
.forEach((element) => {
element.replaceWith(`![|video](${element.dataset.videoSrc})`);
}); });
return toMarkdown(div.outerHTML); return toMarkdown(div.outerHTML);

View File

@ -8,7 +8,6 @@ import { isEmpty } from "@ember/utils";
import { observes, on } from "@ember-decorators/object"; import { observes, on } from "@ember-decorators/object";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { extractError, throwAjaxError } from "discourse/lib/ajax-error"; import { extractError, throwAjaxError } from "discourse/lib/ajax-error";
import { propertyNotEqual } from "discourse/lib/computed";
import { QUOTE_REGEXP } from "discourse/lib/quote"; import { QUOTE_REGEXP } from "discourse/lib/quote";
import { prioritizeNameFallback } from "discourse/lib/settings"; import { prioritizeNameFallback } from "discourse/lib/settings";
import { emailValid, escapeExpression } from "discourse/lib/utilities"; import { emailValid, escapeExpression } from "discourse/lib/utilities";
@ -73,13 +72,15 @@ const CLOSED = "closed",
_update_serializer = { _update_serializer = {
raw: "reply", raw: "reply",
topic_id: "topic.id", topic_id: "topic.id",
raw_old: "rawOld", original_text: "originalText",
}, },
_edit_topic_serializer = { _edit_topic_serializer = {
title: "topic.title", title: "topic.title",
categoryId: "topic.category.id", categoryId: "topic.category.id",
tags: "topic.tags", tags: "topic.tags",
featuredLink: "topic.featured_link", featuredLink: "topic.featured_link",
original_title: "originalTitle",
original_tags: "originalTags",
}, },
_draft_serializer = { _draft_serializer = {
reply: "reply", reply: "reply",
@ -94,6 +95,9 @@ const CLOSED = "closed",
typingTime: "typingTime", typingTime: "typingTime",
postId: "post.id", postId: "post.id",
recipients: "targetRecipients", recipients: "targetRecipients",
original_text: "originalText",
original_title: "originalTitle",
original_tags: "originalTags",
}, },
_add_draft_fields = {}, _add_draft_fields = {},
FAST_REPLY_LENGTH_THRESHOLD = 10000; FAST_REPLY_LENGTH_THRESHOLD = 10000;
@ -210,8 +214,6 @@ export default class Composer extends RestModel {
@equal("composeState", FULLSCREEN) viewFullscreen; @equal("composeState", FULLSCREEN) viewFullscreen;
@or("viewOpen", "viewFullscreen") viewOpenOrFullscreen; @or("viewOpen", "viewFullscreen") viewOpenOrFullscreen;
@and("editingPost", "post.firstPost") editingFirstPost; @and("editingPost", "post.firstPost") editingFirstPost;
@propertyNotEqual("reply", "originalText") replyDirty;
@propertyNotEqual("title", "originalTitle") titleDirty;
@or( @or(
"creatingTopic", "creatingTopic",
@ -226,6 +228,16 @@ export default class Composer extends RestModel {
@tracked _categoryId = null; @tracked _categoryId = null;
@discourseComputed("reply", "originalText")
replyDirty(reply, original) {
return (reply || "").trim() !== (original || "").trim();
}
@discourseComputed("title", "originalTitle")
titleDirty(title, original) {
return (title || "").trim() !== (original || "").trim();
}
@dependentKeyCompat @dependentKeyCompat
get categoryId() { get categoryId() {
return this._categoryId; return this._categoryId;
@ -787,6 +799,7 @@ export default class Composer extends RestModel {
const category = Category.findById(categoryId); const category = Category.findById(categoryId);
if (category) { if (category) {
this.set("reply", category.topic_template || ""); this.set("reply", category.topic_template || "");
this.set("originalText", category.topic_template || "");
} }
} }
@ -861,6 +874,9 @@ export default class Composer extends RestModel {
whisper: opts.whisper, whisper: opts.whisper,
tags: opts.tags || [], tags: opts.tags || [],
noBump: opts.noBump, noBump: opts.noBump,
originalText: opts.originalText,
originalTitle: opts.originalTitle,
originalTags: opts.originalTags,
}); });
if (opts.post) { if (opts.post) {
@ -926,6 +942,13 @@ export default class Composer extends RestModel {
reply: post.raw, reply: post.raw,
originalText: post.raw, originalText: post.raw,
}); });
if (post.post_number === 1 && this.canEditTitle) {
this.setProperties({
originalTitle: post.topic.title,
originalTags: post.topic.tags,
});
}
}); });
// edge case ... make a post then edit right away // edge case ... make a post then edit right away
@ -945,24 +968,18 @@ export default class Composer extends RestModel {
}); });
}); });
} else if (opts.action === REPLY && opts.quote) { } else if (opts.action === REPLY && opts.quote) {
this.setProperties({ this.set("reply", opts.quote);
reply: opts.quote, this.set("originalText", opts.quote);
originalText: opts.quote,
});
} }
if (opts.title) { if (opts.title) {
this.set("title", opts.title); this.set("title", opts.title);
} }
const isDraft = opts.draft || opts.skipDraftCheck;
this.set("originalText", isDraft ? "" : this.reply);
if (this.canEditTitle) { if (this.canEditTitle) {
if (isEmpty(this.title) && this.title !== "") { if (isEmpty(this.title) && this.title !== "") {
this.set("title", ""); this.set("title", "");
} }
this.set("originalTitle", this.title);
} }
if (!isEdit(opts.action) || !opts.post) { if (!isEdit(opts.action) || !opts.post) {
@ -1001,6 +1018,8 @@ export default class Composer extends RestModel {
clearState() { clearState() {
this.setProperties({ this.setProperties({
originalText: null, originalText: null,
originalTitle: null,
originalTags: null,
reply: null, reply: null,
post: null, post: null,
title: null, title: null,
@ -1016,12 +1035,9 @@ export default class Composer extends RestModel {
}); });
} }
@discourseComputed("editConflict", "originalText")
rawOld(editConflict, originalText) {
return editConflict ? null : originalText;
}
editPost(opts) { editPost(opts) {
this.set("composeState", SAVING);
const post = this.post; const post = this.post;
const oldCooked = post.cooked; const oldCooked = post.cooked;
let promise = Promise.resolve(); let promise = Promise.resolve();
@ -1054,14 +1070,19 @@ export default class Composer extends RestModel {
} }
} }
const props = { let props = {
edit_reason: opts.editReason, edit_reason: opts.editReason,
image_sizes: opts.imageSizes, image_sizes: opts.imageSizes,
cooked: this.getCookedHtml(),
}; };
this.serialize(_update_serializer, props); this.serialize(_update_serializer, props);
this.set("composeState", SAVING);
// user clicked "overwrite edits" button
if (this.editConflict) {
delete props.original_text;
delete props.original_title;
delete props.original_tags;
}
const rollback = throwAjaxError((error) => { const rollback = throwAjaxError((error) => {
post.setProperties("cooked", oldCooked); post.setProperties("cooked", oldCooked);
@ -1071,7 +1092,8 @@ export default class Composer extends RestModel {
} }
}); });
post.setProperties({ cooked: props.cooked, staged: true }); const cooked = this.getCookedHtml();
post.setProperties({ cooked, staged: true });
this.appEvents.trigger("post-stream:refresh", { id: post.id }); this.appEvents.trigger("post-stream:refresh", { id: post.id });
return promise return promise
@ -1292,16 +1314,9 @@ export default class Composer extends RestModel {
return Promise.resolve(); return Promise.resolve();
} }
this.setProperties({ this.set("draftSaving", true);
draftSaving: true,
draftConflictUser: null,
});
let data = this.serialize(_draft_serializer); const data = this.serialize(_draft_serializer);
if (data.postId && !isEmpty(this.originalText)) {
data.originalText = this.originalText;
}
const draftSequence = this.draftSequence; const draftSequence = this.draftSequence;
this.set("draftSequence", this.draftSequence + 1); this.set("draftSequence", this.draftSequence + 1);

View File

@ -21,14 +21,10 @@ export default class HistoryStore extends Service {
#routeData = new TrackedMap(); #routeData = new TrackedMap();
#uuid; #uuid;
#pendingStore; #pendingStore = DEBUG && isTesting() ? new TrackedMap() : null;
get #currentStore() { get #currentStore() {
if (this.#pendingStore) { return this.#pendingStore || this.#dataFor(this.#uuid);
return this.#pendingStore;
}
return this.#dataFor(this.#uuid);
} }
/** /**
@ -84,10 +80,7 @@ export default class HistoryStore extends Service {
if (key === undefined) { if (key === undefined) {
continue; continue;
} }
if (key === this.#uuid) { return key !== this.#uuid;
return false;
}
return true;
} }
} }
@ -126,7 +119,6 @@ export default class HistoryStore extends Service {
if (DEBUG && isTesting()) { if (DEBUG && isTesting()) {
// Can't use window.history in tests // Can't use window.history in tests
this.#pendingStore = new TrackedMap();
return; return;
} }

View File

@ -1,6 +1,10 @@
<PluginOutlet <PluginOutlet
@name="about-wrapper" @name="about-wrapper"
@outletArgs={{hash model=this.model contactInfo=this.contactInfo}} @outletArgs={{hash
model=this.model
contactInfo=this.contactInfo
faqOverridden=this.faqOverridden
}}
> >
{{body-class "about-page"}} {{body-class "about-page"}}

View File

@ -52,6 +52,7 @@
<div class="edit-topic-title"> <div class="edit-topic-title">
<PrivateMessageGlyph @shouldShow={{this.model.isPrivateMessage}} /> <PrivateMessageGlyph @shouldShow={{this.model.isPrivateMessage}} />
<div class="edit-title__wrapper">
<PluginOutlet <PluginOutlet
@name="edit-topic-title" @name="edit-topic-title"
@outletArgs={{hash model=this.model buffered=this.buffered}} @outletArgs={{hash model=this.model buffered=this.buffered}}
@ -63,8 +64,10 @@
@autofocus="true" @autofocus="true"
/> />
</PluginOutlet> </PluginOutlet>
</div>
{{#if this.showCategoryChooser}} {{#if this.showCategoryChooser}}
<div class="edit-category__wrapper">
<PluginOutlet <PluginOutlet
@name="edit-topic-category" @name="edit-topic-category"
@outletArgs={{hash model=this.model buffered=this.buffered}} @outletArgs={{hash model=this.model buffered=this.buffered}}
@ -75,9 +78,11 @@
class="small" class="small"
/> />
</PluginOutlet> </PluginOutlet>
</div>
{{/if}} {{/if}}
{{#if this.canEditTags}} {{#if this.canEditTags}}
<div class="edit-tags__wrapper">
<PluginOutlet <PluginOutlet
@name="edit-topic-tags" @name="edit-topic-tags"
@outletArgs={{hash model=this.model buffered=this.buffered}} @outletArgs={{hash model=this.model buffered=this.buffered}}
@ -94,16 +99,14 @@
}} }}
/> />
</PluginOutlet> </PluginOutlet>
</div>
{{/if}} {{/if}}
<span>
<PluginOutlet <PluginOutlet
@name="edit-topic" @name="edit-topic"
@connectorTagName="div" @connectorTagName="div"
@outletArgs={{hash model=this.model buffered=this.buffered}} @outletArgs={{hash model=this.model buffered=this.buffered}}
/> />
</span>
<div class="edit-controls"> <div class="edit-controls">
<DButton <DButton

View File

@ -1,6 +1,6 @@
{{body-class "user-activity-page"}} {{body-class "user-activity-page"}}
<PluginOutlet @name="user-activity-navigation-wrapper">
<div class="user-navigation user-navigation-secondary"> <div class="user-navigation user-navigation-secondary">
<HorizontalOverflowNav @ariaLabel="User secondary - activity"> <HorizontalOverflowNav @ariaLabel="User secondary - activity">
<DNavigationItem <DNavigationItem
@route="userActivity.index" @route="userActivity.index"
@ -88,8 +88,8 @@
@outletArgs={{hash model=this.model}} @outletArgs={{hash model=this.model}}
/> />
</HorizontalOverflowNav> </HorizontalOverflowNav>
</div> </div>
</PluginOutlet>
<section class="user-content" id="user-content"> <section class="user-content" id="user-content">
{{outlet}} {{outlet}}
</section> </section>

View File

@ -1,6 +1,8 @@
import { hbs } from "ember-cli-htmlbars";
import { h } from "virtual-dom"; import { h } from "virtual-dom";
import { prioritizeNameInUx } from "discourse/lib/settings"; import { prioritizeNameInUx } from "discourse/lib/settings";
import { formatUsername } from "discourse/lib/utilities"; import { formatUsername } from "discourse/lib/utilities";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { applyDecorators, createWidget } from "discourse/widgets/widget"; import { applyDecorators, createWidget } from "discourse/widgets/widget";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { iconNode } from "discourse-common/lib/icon-library"; import { iconNode } from "discourse-common/lib/icon-library";
@ -148,10 +150,7 @@ export default createWidget("poster-name", {
h("span", { className: classNames.join(" ") }, nameContents), h("span", { className: classNames.join(" ") }, nameContents),
]; ];
if (!this.settings.showNameAndGroup) { if (this.settings.showNameAndGroup) {
return contents;
}
if ( if (
name && name &&
this.siteSettings.display_name_on_posts && this.siteSettings.display_name_on_posts &&
@ -172,6 +171,32 @@ export default createWidget("poster-name", {
if (this.siteSettings.enable_user_status) { if (this.siteSettings.enable_user_status) {
this.addUserStatus(contents, attrs); this.addUserStatus(contents, attrs);
} }
}
if (attrs.badgesGranted?.length) {
const badges = [];
attrs.badgesGranted.forEach((badge) => {
// Alter the badge description to show that the badge was granted for this post.
badge.description = i18n("post.badge_granted_tooltip", {
username: attrs.username,
badge_name: badge.name,
});
const badgeIcon = new RenderGlimmer(
this,
`span.user-badge-button-${badge.slug}`,
hbs`<UserBadge @badge={{@data.badge}} @user={{@data.user}} @showName={{false}} />`,
{
badge,
user: attrs.user,
}
);
badges.push(badgeIcon);
});
contents.push(h("span.user-badge-buttons", badges));
}
return contents; return contents;
}, },

View File

@ -15,8 +15,8 @@
"test": "ember test" "test": "ember test"
}, },
"dependencies": { "dependencies": {
"@faker-js/faker": "^9.2.0", "@faker-js/faker": "^9.3.0",
"@glimmer/syntax": "^0.92.3", "@glimmer/syntax": "^0.93.1",
"@highlightjs/cdn-assets": "^11.10.0", "@highlightjs/cdn-assets": "^11.10.0",
"@json-editor/json-editor": "2.15.2", "@json-editor/json-editor": "2.15.2",
"@messageformat/core": "^3.4.0", "@messageformat/core": "^3.4.0",
@ -56,7 +56,7 @@
"@glimmer/component": "^1.1.2", "@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2", "@glimmer/tracking": "^1.1.2",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@swc/core": "^1.9.3", "@swc/core": "^1.10.0",
"@types/jquery": "^3.5.32", "@types/jquery": "^3.5.32",
"@types/qunit": "^2.19.12", "@types/qunit": "^2.19.12",
"@types/rsvp": "^4.0.9", "@types/rsvp": "^4.0.9",
@ -107,13 +107,13 @@
"imports-loader": "^5.0.0", "imports-loader": "^5.0.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsuites": "^5.7.2", "jsuites": "^5.8.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"make-plural": "^7.4.0", "make-plural": "^7.4.0",
"message-bus-client": "^4.3.8", "message-bus-client": "^4.3.8",
"pretender": "^3.4.7", "pretender": "^3.4.7",
"qunit": "^2.22.0", "qunit": "^2.23.0",
"qunit-dom": "^3.3.0", "qunit-dom": "^3.4.0",
"sass": "^1.77.7", "sass": "^1.77.7",
"select-kit": "workspace:1.0.0", "select-kit": "workspace:1.0.0",
"sinon": "^19.0.2", "sinon": "^19.0.2",
@ -123,7 +123,7 @@
"truth-helpers": "workspace:1.0.0", "truth-helpers": "workspace:1.0.0",
"util": "^0.12.5", "util": "^0.12.5",
"virtual-dom": "^2.1.1", "virtual-dom": "^2.1.1",
"webpack": "^5.96.1", "webpack": "^5.97.0",
"webpack-retry-chunk-load-plugin": "^3.1.1", "webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3", "webpack-stats-plugin": "^1.1.3",
"xss": "^1.0.15" "xss": "^1.0.15"

View File

@ -6,7 +6,6 @@ import { toggleCheckDraftPopup } from "discourse/services/composer";
import userFixtures from "discourse/tests/fixtures/user-fixtures"; import userFixtures from "discourse/tests/fixtures/user-fixtures";
import { import {
acceptance, acceptance,
query,
selectText, selectText,
updateCurrentUser, updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
@ -127,7 +126,7 @@ acceptance("Composer Actions", function (needs) {
assert.strictEqual(categoryChooserReplyArea.header().name(), "faq"); assert.strictEqual(categoryChooserReplyArea.header().name(), "faq");
assert.dom(".action-title").hasText(i18n("topic.create_long")); assert.dom(".action-title").hasText(i18n("topic.create_long"));
assert.true(query(".d-editor-input").value.includes(quote)); assert.dom(".d-editor-input").includesValue(quote);
}); });
test("reply_as_new_topic without a new_topic draft", async function (assert) { test("reply_as_new_topic without a new_topic draft", async function (assert) {
@ -210,7 +209,7 @@ acceptance("Composer Actions", function (needs) {
await composerActions.expand(); await composerActions.expand();
assert.dom(".action-title").hasText(i18n("topic.create_long")); assert.dom(".action-title").hasText(i18n("topic.create_long"));
assert.true(query(".d-editor-input").value.includes(quote)); assert.dom(".d-editor-input").includesValue(quote);
assert.strictEqual(composerActions.rowByIndex(0).value(), "reply_to_post"); assert.strictEqual(composerActions.rowByIndex(0).value(), "reply_to_post");
assert.strictEqual(composerActions.rowByIndex(1).value(), "reply_to_topic"); assert.strictEqual(composerActions.rowByIndex(1).value(), "reply_to_topic");
assert.strictEqual(composerActions.rowByIndex(2).value(), "shared_draft"); assert.strictEqual(composerActions.rowByIndex(2).value(), "shared_draft");

View File

@ -13,17 +13,17 @@ acceptance("Composer - Edit conflict", function (needs) {
}); });
}); });
test("Should not send originalText when posting a new reply", async function (assert) { test("Should not send 'original_text' when posting a new reply", async function (assert) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click(".topic-post:nth-of-type(1) button.reply"); await click(".topic-post:nth-of-type(1) button.reply");
await fillIn( await fillIn(
".d-editor-input", ".d-editor-input",
"hello world hello world hello world hello world hello world" "hello world hello world hello world hello world hello world"
); );
assert.false(lastBody.includes("originalText")); assert.false(lastBody.includes("original_text"));
}); });
test("Should send originalText when editing a reply", async function (assert) { test("Should send 'original_text' when editing a reply", async function (assert) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".topic-post:nth-of-type(1) button.show-more-actions");
await click(".topic-post:nth-of-type(1) button.edit"); await click(".topic-post:nth-of-type(1) button.edit");
@ -31,6 +31,6 @@ acceptance("Composer - Edit conflict", function (needs) {
".d-editor-input", ".d-editor-input",
"hello world hello world hello world hello world hello world" "hello world hello world hello world hello world hello world"
); );
assert.true(lastBody.includes("originalText")); assert.true(lastBody.includes("original_text"));
}); });
}); });

View File

@ -1,7 +1,7 @@
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("Composer - Image Preview", function (needs) { acceptance("Composer - Image Preview", function (needs) {
needs.user({}); needs.user({});
@ -339,10 +339,9 @@ acceptance("Composer - Image Preview", function (needs) {
.dom(".d-editor-input") .dom(".d-editor-input")
.hasValue(uploads.join("\n"), "Image should be removed from the editor"); .hasValue(uploads.join("\n"), "Image should be removed from the editor");
assert.false( assert
query(".d-editor-input").value.includes("image_example_0"), .dom(".d-editor-input")
"does not have the first image" .doesNotIncludeValue("image_example_0", "does not have the first image");
);
assert assert
.dom(".d-editor-input") .dom(".d-editor-input")

View File

@ -576,7 +576,7 @@ acceptance("Composer", function (needs) {
); );
}); });
test("Composer can toggle between edit and reply", async function (assert) { test("Composer can toggle between edit and reply on the OP", async function (assert) {
await visit("/t/this-is-a-test-topic/9"); await visit("/t/this-is-a-test-topic/9");
await click(".topic-post:nth-of-type(1) button.edit"); await click(".topic-post:nth-of-type(1) button.edit");
@ -586,8 +586,10 @@ acceptance("Composer", function (needs) {
/^This is the first post\./, /^This is the first post\./,
"populates the input with the post text" "populates the input with the post text"
); );
await click(".topic-post:nth-of-type(1) button.reply"); await click(".topic-post:nth-of-type(1) button.reply");
assert.dom(".d-editor-input").hasNoValue("clears the input"); assert.dom(".d-editor-input").hasNoValue("clears the composer input");
await click(".topic-post:nth-of-type(1) button.edit"); await click(".topic-post:nth-of-type(1) button.edit");
assert assert
.dom(".d-editor-input") .dom(".d-editor-input")
@ -597,6 +599,27 @@ acceptance("Composer", function (needs) {
); );
}); });
test("Composer can toggle between edit and reply on a reply", async function (assert) {
await visit("/t/this-is-a-test-topic/9");
await click(".topic-post:nth-of-type(2) button.edit");
assert
.dom(".d-editor-input")
.hasValue(
/^This is the second post\./,
"populates the input with the post text"
);
await click(".topic-post:nth-of-type(2) button.reply");
assert.dom(".d-editor-input").hasNoValue("clears the composer input");
await click(".topic-post:nth-of-type(2) button.edit");
assert.true(
query(".d-editor-input").value.startsWith("This is the second post."),
"populates the input with the post text"
);
});
test("Composer can toggle whispers when whisperer user", async function (assert) { test("Composer can toggle whispers when whisperer user", async function (assert) {
const menu = selectKit(".toolbar-popup-menu-options"); const menu = selectKit(".toolbar-popup-menu-options");
@ -802,6 +825,7 @@ acceptance("Composer", function (needs) {
i18n("post.cancel_composer.keep_editing"), i18n("post.cancel_composer.keep_editing"),
"has keep editing button" "has keep editing button"
); );
await click(".d-modal__footer button.save-draft"); await click(".d-modal__footer button.save-draft");
assert.dom(".d-editor-input").hasNoValue("clears the composer input"); assert.dom(".d-editor-input").hasNoValue("clears the composer input");
}); });
@ -847,10 +871,9 @@ acceptance("Composer", function (needs) {
assert.dom(".d-modal__body").doesNotExist("abandon popup shouldn't come"); assert.dom(".d-modal__body").doesNotExist("abandon popup shouldn't come");
assert.true( assert
query(".d-editor-input").value.includes(longText), .dom(".d-editor-input")
"entered text should still be there" .includesValue(longText, "entered text should still be there");
);
assert assert
.dom('.action-title a[href="/t/internationalization-localization/280"]') .dom('.action-title a[href="/t/internationalization-localization/280"]')
@ -1293,32 +1316,6 @@ acceptance("Composer - default category not set", function (needs) {
}); });
// END: Default Composer Category tests // END: Default Composer Category tests
acceptance("Composer - current time", function (needs) {
needs.user();
test("composer insert current time shortcut", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create");
assert.dom(".d-editor-input").exists("the composer input is visible");
await fillIn(".d-editor-input", "and the time now is: ");
const date = moment().format("YYYY-MM-DD");
await triggerKeyEvent(".d-editor-input", "keydown", ".", {
...metaModifier,
shiftKey: true,
});
assert.true(
query("#reply-control .d-editor-input")
.value.trim()
.startsWith(`and the time now is: [date=${date}`),
"adds the current date"
);
});
});
acceptance("composer buttons API", function (needs) { acceptance("composer buttons API", function (needs) {
needs.user(); needs.user();
needs.settings({ needs.settings({

View File

@ -627,10 +627,10 @@ acceptance("Search - Authenticated", function (needs) {
.dom(".d-editor-input") .dom(".d-editor-input")
.hasValue(/a link/, "still has the original composer content"); .hasValue(/a link/, "still has the original composer content");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
searchFixtures["search/query"].topics[0].slug .includesValue(
), searchFixtures["search/query"].topics[0].slug,
"adds link from search to composer" "adds link from search to composer"
); );
}); });

View File

@ -1,7 +1,7 @@
import { click, currentURL, visit } from "@ember/test-helpers"; import { click, currentURL, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import CategoryFixtures from "discourse/tests/fixtures/category-fixtures"; import CategoryFixtures from "discourse/tests/fixtures/category-fixtures";
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers"; import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -37,11 +37,11 @@ acceptance("Share and Invite modal", function (needs) {
.dom("#modal-alert.alert-warning") .dom("#modal-alert.alert-warning")
.doesNotExist("it does not show the alert with restricted groups"); .doesNotExist("it does not show the alert with restricted groups");
assert.true( assert
query("input.invite-link").value.includes( .dom("input.invite-link")
"/t/internationalization-localization/280?u=eviltrout" .includesValue(
), "/t/internationalization-localization/280?u=eviltrout",
"it shows the topic sharing url" "shows the topic sharing url"
); );
assert assert
@ -127,9 +127,11 @@ acceptance("Share url with badges disabled - desktop", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-share-and-invite"); await click("#topic-footer-button-share-and-invite");
assert.false( assert
query("input.invite-link").value.includes("?u=eviltrout"), .dom("input.invite-link")
"it doesn't add the username param when badges are disabled" .doesNotIncludeValue(
"?u=eviltrout",
"doesn't add the username param when badges are disabled"
); );
}); });
}); });
@ -148,9 +150,11 @@ acceptance("With username in share links disabled - desktop", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-share-and-invite"); await click("#topic-footer-button-share-and-invite");
assert.false( assert
query("input.invite-link").value.includes("?u=eviltrout"), .dom("input.invite-link")
"it doesn't add the username param when username in share links are disabled" .doesNotIncludeValue(
"?u=eviltrout",
"doesn't add the username param when username in share links are disabled"
); );
}); });
}); });

View File

@ -13,7 +13,6 @@ import {
acceptance, acceptance,
chromeTest, chromeTest,
publishToMessageBus, publishToMessageBus,
query,
selectText, selectText,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -372,11 +371,9 @@ acceptance("Topic featured links", function (needs) {
await selectText("#post_5 blockquote"); await selectText("#post_5 blockquote");
await click(".quote-button .insert-quote"); await click(".quote-button .insert-quote");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
'quote="codinghorror said, post:3, topic:280"' .includesValue('quote="codinghorror said, post:3, topic:280"');
)
);
}); });
test("Quoting a quote of a different topic keeps the original topic title", async function (assert) { test("Quoting a quote of a different topic keeps the original topic title", async function (assert) {
@ -384,10 +381,10 @@ acceptance("Topic featured links", function (needs) {
await selectText("#post_9 blockquote"); await selectText("#post_9 blockquote");
await click(".quote-button .insert-quote"); await click(".quote-button .insert-quote");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
.includesValue(
'quote="A new topic with a link to another topic, post:3, topic:62"' 'quote="A new topic with a link to another topic, post:3, topic:62"'
)
); );
}); });
@ -396,11 +393,9 @@ acceptance("Topic featured links", function (needs) {
await selectText("#post_5 blockquote"); await selectText("#post_5 blockquote");
await click(".reply"); await click(".reply");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
'quote="codinghorror said, post:3, topic:280"' .includesValue('quote="codinghorror said, post:3, topic:280"');
)
);
}); });
// Using J/K on Firefox clean the text selection, so this won't work there // Using J/K on Firefox clean the text selection, so this won't work there
@ -412,11 +407,9 @@ acceptance("Topic featured links", function (needs) {
await triggerKeyEvent(document, "keypress", "J"); await triggerKeyEvent(document, "keypress", "J");
await triggerKeyEvent(document, "keypress", "T"); await triggerKeyEvent(document, "keypress", "T");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
'quote="codinghorror said, post:3, topic:280"' .includesValue('quote="codinghorror said, post:3, topic:280"');
)
);
} }
); );
@ -424,11 +417,9 @@ acceptance("Topic featured links", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await selectText("#post_5 .cooked"); await selectText("#post_5 .cooked");
await click(".quote-button .insert-quote"); await click(".quote-button .insert-quote");
assert.true( assert
query(".d-editor-input").value.includes( .dom(".d-editor-input")
'quote="pekka, post:5, topic:280, full:true"' .includesValue('quote="pekka, post:5, topic:280, full:true"');
)
);
}); });
}); });

View File

@ -34,7 +34,7 @@ export default {
grant_count: 7, grant_count: 7,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -64,7 +64,7 @@ export default {
grant_count: 30, grant_count: 30,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -334,7 +334,7 @@ export default {
grant_count: 7, grant_count: 7,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -364,7 +364,7 @@ export default {
grant_count: 30, grant_count: 30,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -2462,7 +2462,7 @@ export default {
grant_count: 3, grant_count: 3,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -2758,7 +2758,7 @@ export default {
grant_count: 3, grant_count: 3,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -2837,7 +2837,7 @@ export default {
grant_count: 3, grant_count: 3,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -2907,7 +2907,7 @@ export default {
grant_count: 3, grant_count: 3,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -3195,7 +3195,7 @@ export default {
grant_count: 7, grant_count: 7,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,
@ -3225,7 +3225,7 @@ export default {
grant_count: 30, grant_count: 30,
allow_title: true, allow_title: true,
multiple_grant: false, multiple_grant: false,
icon: "fa-user", icon: "user",
image: null, image: null,
listable: true, listable: true,
enabled: true, enabled: true,

View File

@ -77,4 +77,22 @@ module("Integration | Component | badge-button", function (hooks) {
assert.dom(".user-badge.foo").exists(); assert.dom(".user-badge.foo").exists();
}); });
test("setting showName to false hides the name", async function (assert) {
this.set("badge", { name: "foo" });
await render(
hbs`<BadgeButton @badge={{this.badge}} @showName={{false}} />`
);
assert.dom(".badge-display-name").doesNotExist();
});
test("showName defaults to true", async function (assert) {
this.set("badge", { name: "foo" });
await render(hbs`<BadgeButton @badge={{this.badge}} />`);
assert.dom(".badge-display-name").exists();
});
}); });

View File

@ -344,6 +344,26 @@ third line`
assert.strictEqual(textarea.selectionEnd, 23); assert.strictEqual(textarea.selectionEnd, 23);
}); });
test("code button does not reset undo history", async function (assert) {
this.set("value", "existing");
await render(hbs`<DEditor @value={{this.value}} />`);
const textarea = query("textarea.d-editor-input");
textarea.selectionStart = 0;
textarea.selectionEnd = 8;
await click("button.code");
assert.strictEqual(this.value, "`existing`");
await click("button.code");
assert.strictEqual(this.value, "existing");
document.execCommand("undo");
assert.strictEqual(this.value, "`existing`");
document.execCommand("undo");
assert.strictEqual(this.value, "existing");
});
test("code fences", async function (assert) { test("code fences", async function (assert) {
this.set("value", ""); this.set("value", "");
@ -615,6 +635,22 @@ third line`
assert.strictEqual(textarea.selectionEnd, 18); assert.strictEqual(textarea.selectionEnd, 18);
}); });
testCase(
"list button does not reset undo history",
async function (assert, textarea) {
this.set("value", "existing");
textarea.selectionStart = 0;
textarea.selectionEnd = 8;
await click("button.list");
assert.strictEqual(this.value, "1. existing");
document.execCommand("undo");
assert.strictEqual(this.value, "existing");
}
);
test("clicking the toggle-direction changes dir from ltr to rtl and back", async function (assert) { test("clicking the toggle-direction changes dir from ltr to rtl and back", async function (assert) {
this.siteSettings.support_mixed_text_direction = true; this.siteSettings.support_mixed_text_direction = true;
this.siteSettings.default_locale = "en"; this.siteSettings.default_locale = "en";

View File

@ -31,7 +31,7 @@ module("Integration | Component | select-kit/tag-drop", function (hooks) {
await render(<template> await render(<template>
<TagDrop <TagDrop
@currentCategory={{category}} @currentCategory={{category}}
@tagId={{"jeff"}} @tagId="jeff"
@options={{hash tagId="jeff"}} @options={{hash tagId="jeff"}}
/> />
</template>); </template>);

View File

@ -0,0 +1,61 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import TopicListItem from "discourse/components/topic-list/item";
import HbrTopicListItem from "discourse/components/topic-list-item";
import { withPluginApi } from "discourse/lib/plugin-api";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | topic-list-item", function (hooks) {
setupRenderingTest(hooks);
test("checkbox is rendered checked if topic is in selected array", async function (assert) {
const store = this.owner.lookup("service:store");
const topic = store.createRecord("topic", { id: 24234 });
const topic2 = store.createRecord("topic", { id: 24235 });
const selected = [topic];
await render(<template>
<HbrTopicListItem
@topic={{topic}}
@bulkSelectEnabled={{true}}
@selected={{selected}}
/>
<HbrTopicListItem
@topic={{topic2}}
@bulkSelectEnabled={{true}}
@selected={{selected}}
/>
</template>);
const checkboxes = [...document.querySelectorAll("input.bulk-select")];
assert.dom(checkboxes[0]).isChecked();
assert.dom(checkboxes[1]).isNotChecked();
});
test("topic-list-item-class value transformer", async function (assert) {
withPluginApi("1.39.0", (api) => {
api.registerValueTransformer(
"topic-list-item-class",
({ value, context }) => {
if (context.topic.get("foo")) {
value.push("bar");
}
return value;
}
);
});
const store = this.owner.lookup("service:store");
const topic = store.createRecord("topic", { id: 1234, foo: true });
const topic2 = store.createRecord("topic", { id: 1235, foo: false });
await render(<template>
<TopicListItem @topic={{topic}} />
<TopicListItem @topic={{topic2}} />
</template>);
assert.dom(".topic-list-item[data-topic-id='1234']").hasClass("bar");
assert
.dom(".topic-list-item[data-topic-id='1235']")
.doesNotHaveClass("bar");
});
});

View File

@ -1,37 +0,0 @@
import { getOwner } from "@ember/owner";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | topic-list-item", function (hooks) {
setupRenderingTest(hooks);
test("checkbox is rendered checked if topic is in selected array", async function (assert) {
const store = getOwner(this).lookup("service:store");
const topic = store.createRecord("topic", { id: 24234 });
const topic2 = store.createRecord("topic", { id: 24235 });
this.setProperties({
topic,
topic2,
selected: [topic],
});
await render(hbs`
<TopicListItem
@topic={{this.topic}}
@bulkSelectEnabled={{true}}
@selected={{this.selected}}
/>
<TopicListItem
@topic={{this.topic2}}
@bulkSelectEnabled={{true}}
@selected={{this.selected}}
/>
`);
const checkboxes = [...document.querySelectorAll("input.bulk-select")];
assert.dom(checkboxes[0]).isChecked();
assert.dom(checkboxes[1]).isNotChecked();
});
});

View File

@ -1,6 +1,8 @@
import { render } from "@ember/test-helpers"; import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars"; import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit"; import { module, test } from "qunit";
import Badge from "discourse/models/badge";
import User from "discourse/models/user";
import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | Widget | poster-name", function (hooks) { module("Integration | Component | Widget | poster-name", function (hooks) {
@ -70,4 +72,38 @@ module("Integration | Component | Widget | poster-name", function (hooks) {
assert.dom(".second").doesNotExist(); assert.dom(".second").doesNotExist();
}); });
test("renders badges that are passed in", async function (assert) {
this.set("args", {
username: "eviltrout",
usernameUrl: "/u/eviltrout",
user: User.create({
username: "eviltrout",
}),
badgesGranted: [
{ id: 1, icon: "heart", slug: "badge1", name: "Badge One" },
{ id: 2, icon: "target", slug: "badge2", name: "Badge Two" },
].map((badge) => Badge.createFromJson({ badges: [badge] })[0]),
});
await render(
hbs`<MountWidget @widget="poster-name" @args={{this.args}} />`
);
// Check that the custom CSS classes are set
assert.dom("span.user-badge-button-badge1").exists();
assert.dom("span.user-badge-button-badge2").exists();
// Check that the custom titles are set
assert.dom("span.user-badge[title*='Badge One']").exists();
assert.dom("span.user-badge[title*='Badge Two']").exists();
// Check that the badges link to the correct badge page
assert
.dom("a.user-card-badge-link[href='/badges/1/badge1?username=eviltrout']")
.exists();
assert
.dom("a.user-card-badge-link[href='/badges/2/badge2?username=eviltrout']")
.exists();
});
}); });

View File

@ -1,6 +1,9 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { concat } from "@ember/helper"; import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner"; import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { modifier } from "ember-modifier"; import { modifier } from "ember-modifier";
import { and } from "truth-helpers"; import { and } from "truth-helpers";
@ -31,6 +34,28 @@ export default class DMenu extends Component {
}; };
}); });
@action
registerFloatBody(element) {
this.body = element;
}
@action
forwardTabToContent(event) {
if (!this.body) {
return;
}
if (event.key === "Tab") {
event.preventDefault();
const firstFocusable = this.body.querySelector(
'button, a, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus() || this.body.focus();
}
}
get menuId() { get menuId() {
return `d-menu-${this.menuInstance.id}`; return `d-menu-${this.menuInstance.id}`;
} }
@ -73,6 +98,7 @@ export default class DMenu extends Component {
@translatedTitle={{@title}} @translatedTitle={{@title}}
@disabled={{@disabled}} @disabled={{@disabled}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}} aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
...attributes ...attributes
> >
{{#if (has-block "trigger")}} {{#if (has-block "trigger")}}
@ -122,6 +148,7 @@ export default class DMenu extends Component {
@innerClass="fk-d-menu__inner-content" @innerClass="fk-d-menu__inner-content"
@role="dialog" @role="dialog"
@inline={{this.options.inline}} @inline={{this.options.inline}}
{{didInsert this.registerFloatBody}}
> >
{{#if (has-block)}} {{#if (has-block)}}
{{yield this.componentArgs}} {{yield this.componentArgs}}

View File

@ -39,7 +39,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -40,7 +40,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -40,7 +40,7 @@
"ember-source": "~5.5.0", "ember-source": "~5.5.0",
"ember-source-channel-url": "^3.0.0", "ember-source-channel-url": "^3.0.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"webpack": "^5.96.1" "webpack": "^5.97.0"
}, },
"engines": { "engines": {
"node": ">= 18", "node": ">= 18",

View File

@ -1104,6 +1104,7 @@ a.inline-editable-field {
@import "common/admin/site-settings"; @import "common/admin/site-settings";
@import "common/admin/admin_config_area"; @import "common/admin/admin_config_area";
@import "common/admin/admin_table"; @import "common/admin/admin_table";
@import "common/admin/admin_filter";
@import "common/admin/admin_reports"; @import "common/admin/admin_reports";
@import "common/admin/admin_report"; @import "common/admin/admin_report";
@import "common/admin/admin_report_counters"; @import "common/admin/admin_report_counters";

View File

@ -0,0 +1,12 @@
.d-admin-filter {
background-color: var(--primary-very-low);
padding: var(--space-2);
}
.admin-users-list__search {
min-width: 50%;
}
.admin-filter__input {
width: 100%;
}

View File

@ -8,6 +8,13 @@ em > code {
color: var(--primary); color: var(--primary);
} }
a > code {
padding: 2px 4px;
background: var(--inline-code-bg);
white-space: pre-wrap;
color: var(--tertiary);
}
code { code {
color: var(--primary-very-high); color: var(--primary-very-high);
background: var(--hljs-bg); background: var(--hljs-bg);

View File

@ -257,6 +257,24 @@
.names { .names {
margin-right: auto; margin-right: auto;
.first {
flex-shrink: 0;
}
}
.user-badge-buttons {
display: flex;
flex-shrink: 0;
a {
background: none;
}
.user-badge {
background: none;
border: none;
}
} }
.post-infos { .post-infos {
@ -265,8 +283,9 @@
align-items: center; align-items: center;
} }
.user-status-message { .user-status-message-wrap {
display: flex; display: flex;
flex-shrink: 0;
img.emoji { img.emoji {
width: 1em; width: 1em;

View File

@ -249,9 +249,6 @@
.title-wrapper { .title-wrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
button {
margin: 0 0.5em 0 0;
}
.topic-statuses { .topic-statuses {
line-height: 1.2; line-height: 1.2;
.d-icon { .d-icon {
@ -268,7 +265,9 @@
.title-wrapper { .title-wrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 90%; @media screen and (min-width: 925px) {
width: 90%; // topic title isn't full-width on wide screens
}
} }
h1 { h1 {
margin-bottom: 0; margin-bottom: 0;
@ -282,36 +281,66 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
box-sizing: border-box; box-sizing: border-box;
gap: 0.5em;
width: 100%;
max-width: calc( max-width: calc(
var(--topic-body-width) + (var(--topic-body-width-padding) * 2) + var(--topic-body-width) + (var(--topic-body-width-padding) * 2) +
var(--topic-avatar-width) var(--topic-avatar-width)
); );
#edit-title {
flex: 1 1 100%;
}
.category-chooser,
.mini-tag-chooser {
flex: 1 1 35%;
margin: 0 0 9px 0;
@media all and (max-width: 500px) {
flex: 1 1 100%;
}
}
.mini-tag-chooser {
flex: 1 1 54%;
margin: 0 0 9px 0;
margin-left: 1%; // category at 40%, tag chooser at 58%
@media all and (max-width: 500px) {
margin-left: 0;
}
}
.edit-controls {
flex: 1 1 100%;
}
.select-kit .category-row { .select-kit .category-row {
max-width: unset; max-width: unset;
} }
} }
.edit-title__wrapper {
flex: 1 1 100%;
#edit-title {
width: 100%;
margin: 0;
}
}
.edit-category__wrapper {
flex: 1 1 5%;
@include breakpoint(tablet) {
min-width: 0; // allows category name to shrink to fit narrow screens
}
.select-kit.combo-box.category-chooser {
width: 100%;
}
}
.edit-tags__wrapper {
flex: 1 1 33%;
@include breakpoint(tablet) {
flex: 1 1 100%; // force full row on narrow screens
}
.mini-tag-chooser {
width: 100%;
}
.select-kit-header--filter {
flex-wrap: nowrap; // forces the whole input to wrap if needed, rather than individual tags
min-width: 0;
@include breakpoint(tablet) {
flex-wrap: wrap; // individual tags will need to wrap on narrow screens
}
button {
min-width: 0;
}
}
.multi-select-filter {
flex-shrink: 0;
min-width: 2em; // always provide a minimal space for input
}
}
.edit-controls {
display: flex;
width: 100%;
gap: 0.5em;
}
} }
.private-message-glyph { .private-message-glyph {

View File

@ -43,6 +43,7 @@ body:not(.archetype-private_message) {
var(--topic-avatar-width) + var(--topic-body-width) + var(--topic-avatar-width) + var(--topic-body-width) +
(var(--topic-body-width-padding) * 2) (var(--topic-body-width-padding) * 2)
); );
padding-block: 0.5em;
@include breakpoint(mobile-large) { @include breakpoint(mobile-large) {
font-size: var(--font-down-1); font-size: var(--font-down-1);
@ -91,7 +92,6 @@ body:not(.archetype-private_message) {
} }
&__contents { &__contents {
padding-block: 0.5em;
flex-grow: 1; flex-grow: 1;
.number { .number {

View File

@ -1,3 +1,7 @@
html.modal-open {
overflow: hidden;
}
.d-modal { .d-modal {
--modal-max-width: 600px; --modal-max-width: 600px;
--modal-width: 30em; // set in ems to scale with user font-size --modal-width: 30em; // set in ems to scale with user font-size

View File

@ -18,11 +18,6 @@
z-index: z("base"); z-index: z("base");
margin-bottom: 1em; margin-bottom: 1em;
#edit-title,
.category-chooser,
.edit-controls {
width: 500px;
}
h1 { h1 {
font-size: var(--font-up-4); font-size: var(--font-up-4);
line-height: var(--line-height-medium); line-height: var(--line-height-medium);

View File

@ -1,8 +1,24 @@
// Shared styles // Shared styles
.login-page,
.signup-page,
.invite-page {
#main-outlet,
#main-outlet-wrapper {
padding: 0;
}
}
.login-fullpage, .login-fullpage,
.signup-fullpage, .signup-fullpage,
.invites-show { .invites-show {
justify-content: flex-start;
html.keyboard-visible:not(.ios-device) & {
height: calc((var(--composer-vh, 1vh) * 100) - var(--header-offset));
overflow-y: scroll;
}
.signup-body, .signup-body,
.login-body { .login-body {
flex-direction: column; flex-direction: column;

View File

@ -49,6 +49,10 @@ class Admin::SiteSettingsController < Admin::AdminController
on_failed_policy(:setting_is_visible) do on_failed_policy(:setting_is_visible) do
raise Discourse::InvalidParameters, I18n.t("errors.site_settings.site_setting_is_hidden") raise Discourse::InvalidParameters, I18n.t("errors.site_settings.site_setting_is_hidden")
end end
on_failed_policy(:setting_is_shadowed_globally) do
raise Discourse::InvalidParameters,
I18n.t("errors.site_settings.site_setting_is_shadowed_globally")
end
on_failed_policy(:setting_is_configurable) do on_failed_policy(:setting_is_configurable) do
raise Discourse::InvalidParameters, raise Discourse::InvalidParameters,
I18n.t("errors.site_settings.site_setting_is_unconfigurable") I18n.t("errors.site_settings.site_setting_is_unconfigurable")

View File

@ -96,16 +96,26 @@ class DraftsController < ApplicationController
json = success_json.merge(draft_sequence: sequence) json = success_json.merge(draft_sequence: sequence)
if data.present? # check for conflicts when editing a post
# this is a bit of a kludge we need to remove (all the parsing) too many special cases here if data.present? && data["postId"].present? && data["action"].to_s.start_with?("edit")
# we need to catch action edit and action editSharedDraft original_text = data["original_text"] || data["originalText"]
if data["postId"].present? && data["originalText"].present? && original_title = data["original_title"]
data["action"].to_s.start_with?("edit") original_tags = data["original_tags"]
post = Post.find_by(id: data["postId"])
if post && post.raw != data["originalText"] if original_text.present?
if post = Post.find_by(id: data["postId"])
conflict = original_text != post.raw
if post.post_number == 1
conflict ||= original_title.present? && original_title != post.topic.title
conflict ||=
original_tags.present? && original_tags.sort != post.topic.tags.pluck(:name).sort
end
if conflict
conflict_user = BasicUserSerializer.new(post.last_editor, root: false) conflict_user = BasicUserSerializer.new(post.last_editor, root: false)
render json: json.merge(conflict_user: conflict_user) json.merge!(conflict_user:)
return end
end end
end end
end end

View File

@ -240,8 +240,9 @@ class PostsController < ApplicationController
Post.plugin_permitted_update_params.keys.each { |param| changes[param] = params[:post][param] } Post.plugin_permitted_update_params.keys.each { |param| changes[param] = params[:post][param] }
raw_old = params[:post][:raw_old] # keep `raw_old` for backwards compatibility
if raw_old.present? && raw_old != post.raw original_text = params[:post][:original_text] || params[:post][:raw_old]
if original_text.present? && original_text != post.raw
return render_json_error(I18n.t("edit_conflict"), status: 409) return render_json_error(I18n.t("edit_conflict"), status: 409)
end end

View File

@ -359,8 +359,19 @@ class TopicsController < ApplicationController
def update def update
topic = Topic.find_by(id: params[:topic_id]) topic = Topic.find_by(id: params[:topic_id])
guardian.ensure_can_edit!(topic) guardian.ensure_can_edit!(topic)
original_title = params[:original_title]
if original_title.present? && original_title != topic.title
return render_json_error(I18n.t("edit_conflict"), status: 409)
end
original_tags = params[:original_tags]
if original_tags.present? && original_tags.sort != topic.tags.pluck(:name).sort
return render_json_error(I18n.t("edit_conflict"), status: 409)
end
if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i) if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i)
if topic.shared_draft if topic.shared_draft
topic.shared_draft.update(category_id: params[:category_id]) topic.shared_draft.update(category_id: params[:category_id])

View File

@ -363,6 +363,7 @@ end
# trigger :integer # trigger :integer
# show_posts :boolean default(FALSE), not null # show_posts :boolean default(FALSE), not null
# system :boolean default(FALSE), not null # system :boolean default(FALSE), not null
# show_in_post_header :boolean default(FALSE), not null
# long_description :text # long_description :text
# image_upload_id :integer # image_upload_id :integer
# #

View File

@ -327,6 +327,6 @@ class Emoji
end end
def self.sanitize_emoji_name(name) def self.sanitize_emoji_name(name)
name.gsub(/[^a-z0-9]+/i, "_").gsub(/_{2,}/, "_").downcase name.gsub(/[^a-z0-9\+\-]+/i, "_").gsub(/_{2,}/, "_").downcase
end end
end end

View File

@ -658,6 +658,7 @@ class Post < ActiveRecord::Base
"flag_reasons.#{post_action_type_view.types[post_action_type_id]}", "flag_reasons.#{post_action_type_view.types[post_action_type_id]}",
locale: SiteSetting.default_locale, locale: SiteSetting.default_locale,
base_path: Discourse.base_path, base_path: Discourse.base_path,
default: PostActionType.names[post_action_type_id],
), ),
} }

View File

@ -113,6 +113,11 @@ class PostMover
close_topic_and_schedule_deletion if moving_all_posts close_topic_and_schedule_deletion if moving_all_posts
destination_topic.reload destination_topic.reload
DiscourseEvent.trigger(
:posts_moved,
destination_topic_id: destination_topic.id,
original_topic_id: original_topic.id,
)
destination_topic destination_topic
end end
@ -299,6 +304,7 @@ class PostMover
end end
moved_post.attributes = update moved_post.attributes = update
moved_post.disable_rate_limits! if @options[:freeze_original]
moved_post.save(validate: false) moved_post.save(validate: false)
DiscourseEvent.trigger(:post_moved, moved_post, original_topic.id) DiscourseEvent.trigger(:post_moved, moved_post, original_topic.id)

View File

@ -35,6 +35,19 @@ class UserBadge < ActiveRecord::Base
scope :for_enabled_badges, scope :for_enabled_badges,
-> { where("user_badges.badge_id IN (SELECT id FROM badges WHERE enabled)") } -> { where("user_badges.badge_id IN (SELECT id FROM badges WHERE enabled)") }
scope :by_post_and_user,
->(posts) do
posts.reduce(UserBadge.none) do |scope, post|
scope.or(UserBadge.where(user_id: post.user_id, post_id: post.id))
end
end
scope :for_post_header_badges,
->(posts) do
by_post_and_user(posts).where(
"user_badges.badge_id IN (SELECT id FROM badges WHERE show_posts AND enabled AND listable AND show_in_post_header)",
)
end
validates :badge_id, presence: true, uniqueness: { scope: :user_id }, if: :single_grant_badge? validates :badge_id, presence: true, uniqueness: { scope: :user_id }, if: :single_grant_badge?
validates :user_id, presence: true validates :user_id, presence: true

View File

@ -16,7 +16,8 @@ class BadgeSerializer < ApplicationSerializer
:long_description, :long_description,
:slug, :slug,
:has_badge, :has_badge,
:manually_grantable? :manually_grantable?,
:show_in_post_header
has_one :badge_type has_one :badge_type

View File

@ -38,6 +38,7 @@ class PostSerializer < BasicPostSerializer
:flair_bg_color, :flair_bg_color,
:flair_color, :flair_color,
:flair_group_id, :flair_group_id,
:badges_granted,
:version, :version,
:can_edit, :can_edit,
:can_delete, :can_delete,
@ -223,6 +224,18 @@ class PostSerializer < BasicPostSerializer
object.user&.flair_group_id object.user&.flair_group_id
end end
def badges_granted
return [] unless SiteSetting.enable_badges && SiteSetting.show_badges_in_post_header
if @topic_view
user_badges = @topic_view.post_user_badges[object.id] || []
else
user_badges = UserBadge.for_post_header_badges([object])
end
user_badges.map { |user_badge| BasicUserBadgeSerializer.new(user_badge, scope: scope).as_json }
end
def link_counts def link_counts
return @single_post_link_counts if @single_post_link_counts.present? return @single_post_link_counts if @single_post_link_counts.present?

View File

@ -30,6 +30,7 @@ class WebHookPostSerializer < PostSerializer
flair_color flair_color
notice notice
mentioned_users mentioned_users
badges_granted
].each { |attr| define_method("include_#{attr}?") { false } } ].each { |attr| define_method("include_#{attr}?") { false } }
def topic_posts def topic_posts

View File

@ -4,12 +4,15 @@ class AdminNotices::Dismiss
include Service::Base include Service::Base
policy :invalid_access policy :invalid_access
params do params do
attribute :id, :integer attribute :id, :integer
validates :id, presence: true validates :id, presence: true
end end
model :admin_notice, optional: true model :admin_notice, optional: true
transaction do transaction do
step :destroy step :destroy
step :reset_problem_check step :reset_problem_check

View File

@ -4,10 +4,12 @@ class Experiments::Toggle
include Service::Base include Service::Base
policy :current_user_is_admin policy :current_user_is_admin
params do params do
attribute :setting_name, :string attribute :setting_name, :string
validates :setting_name, presence: true validates :setting_name, presence: true
end end
policy :setting_is_available policy :setting_is_available
transaction { step :toggle } transaction { step :toggle }

View File

@ -4,6 +4,7 @@ class Flags::CreateFlag
include Service::Base include Service::Base
policy :invalid_access policy :invalid_access
params do params do
attribute :name, :string attribute :name, :string
attribute :description, :string attribute :description, :string
@ -18,8 +19,10 @@ class Flags::CreateFlag
validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH } validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH }
validates :applies_to, inclusion: { in: -> { Flag.valid_applies_to_types } }, allow_nil: false validates :applies_to, inclusion: { in: -> { Flag.valid_applies_to_types } }, allow_nil: false
end end
policy :unique_name policy :unique_name
model :flag, :instantiate_flag model :flag, :instantiate_flag
transaction do transaction do
step :create step :create
step :log step :log

View File

@ -8,10 +8,12 @@ class Flags::DestroyFlag
validates :id, presence: true validates :id, presence: true
end end
model :flag model :flag
policy :not_system policy :not_system
policy :not_used policy :not_used
policy :invalid_access policy :invalid_access
transaction do transaction do
step :destroy step :destroy
step :log step :log

View File

@ -10,10 +10,12 @@ class Flags::ReorderFlag
validates :flag_id, presence: true validates :flag_id, presence: true
validates :direction, inclusion: { in: %w[up down] } validates :direction, inclusion: { in: %w[up down] }
end end
model :flag model :flag
policy :invalid_access policy :invalid_access
model :all_flags model :all_flags
policy :invalid_move policy :invalid_move
transaction do transaction do
step :move step :move
step :log step :log

View File

@ -4,12 +4,15 @@ class Flags::ToggleFlag
include Service::Base include Service::Base
policy :invalid_access policy :invalid_access
params do params do
attribute :flag_id, :integer attribute :flag_id, :integer
validates :flag_id, presence: true validates :flag_id, presence: true
end end
model :flag model :flag
transaction do transaction do
step :toggle step :toggle
step :log step :log

View File

@ -19,11 +19,13 @@ class Flags::UpdateFlag
validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH } validates :description, length: { maximum: Flag::MAX_DESCRIPTION_LENGTH }
validates :applies_to, inclusion: { in: -> { Flag.valid_applies_to_types } }, allow_nil: false validates :applies_to, inclusion: { in: -> { Flag.valid_applies_to_types } }, allow_nil: false
end end
model :flag model :flag
policy :not_system policy :not_system
policy :not_used policy :not_used
policy :invalid_access policy :invalid_access
policy :unique_name policy :unique_name
transaction do transaction do
step :update step :update
step :log step :log

View File

@ -6,6 +6,7 @@ class SiteSetting::Update
options { attribute :allow_changing_hidden, :boolean, default: false } options { attribute :allow_changing_hidden, :boolean, default: false }
policy :current_user_is_admin policy :current_user_is_admin
params do params do
attribute :setting_name attribute :setting_name
attribute :new_value attribute :new_value
@ -34,6 +35,8 @@ class SiteSetting::Update
end end
end end
end end
policy :setting_is_shadowed_globally
policy :setting_is_visible policy :setting_is_visible
policy :setting_is_configurable policy :setting_is_configurable
step :save step :save
@ -44,6 +47,10 @@ class SiteSetting::Update
guardian.is_admin? guardian.is_admin?
end end
def setting_is_shadowed_globally(params:)
!SiteSetting.shadowed_settings.include?(params.setting_name)
end
def setting_is_visible(params:, options:) def setting_is_visible(params:, options:)
options.allow_changing_hidden || !SiteSetting.hidden_settings.include?(params.setting_name) options.allow_changing_hidden || !SiteSetting.hidden_settings.include?(params.setting_name)
end end

View File

@ -19,6 +19,7 @@ class User::Silence
validates :other_user_ids, length: { maximum: User::MAX_SIMILAR_USERS } validates :other_user_ids, length: { maximum: User::MAX_SIMILAR_USERS }
validates :post_action, inclusion: { in: %w[delete delete_replies edit] }, allow_blank: true validates :post_action, inclusion: { in: %w[delete delete_replies edit] }, allow_blank: true
end end
model :user model :user
policy :not_silenced_already, class_name: User::Policy::NotAlreadySilenced policy :not_silenced_already, class_name: User::Policy::NotAlreadySilenced
model :users model :users

View File

@ -19,6 +19,7 @@ class User::Suspend
validates :other_user_ids, length: { maximum: User::MAX_SIMILAR_USERS } validates :other_user_ids, length: { maximum: User::MAX_SIMILAR_USERS }
validates :post_action, inclusion: { in: %w[delete delete_replies edit] }, allow_blank: true validates :post_action, inclusion: { in: %w[delete delete_replies edit] }, allow_blank: true
end end
model :user model :user
policy :not_suspended_already, class_name: User::Policy::NotAlreadySuspended policy :not_suspended_already, class_name: User::Policy::NotAlreadySuspended
model :users model :users

View File

@ -22,7 +22,7 @@
<%- if can_sign_up? %> <%- if can_sign_up? %>
<a href="<%= path "/signup"%>" class='btn btn-primary btn-small sign-up-button'><%= I18n.t('sign_up') %></a> <a href="<%= path "/signup"%>" class='btn btn-primary btn-small sign-up-button'><%= I18n.t('sign_up') %></a>
<%- end %> <%- end %>
<a href="<%= path "/login"%>" class='btn btn-primary btn-small login-button btn-icon-text'><%= SvgSprite.raw_svg('fa-user') %><%= I18n.t('log_in') %></a> <a href="<%= path "/login"%>" class='btn btn-primary btn-small login-button btn-icon-text'><%= SvgSprite.raw_svg('user') %><%= I18n.t('log_in') %></a>
</span> </span>
<%- end %> <%- end %>
</div> </div>

View File

@ -12,7 +12,7 @@
<h1 class="title"><%= @title %></h1> <h1 class="title"><%= @title %></h1>
<%- if !@current_user %> <%- if !@current_user %>
<a href="<%= path "/login" %>" class='btn btn-primary'><%= SvgSprite.raw_svg('fa-user') %><%= I18n.t('log_in') %></a> <a href="<%= path "/login" %>" class='btn btn-primary'><%= SvgSprite.raw_svg('user') %><%= I18n.t('log_in') %></a>
<%- end %> <%- end %>
<%- if @group&.dig(:allow_membership_requests) %> <%- if @group&.dig(:allow_membership_requests) %>

View File

@ -4920,7 +4920,6 @@ ar:
with_topics: "موضوعات %{filter}" with_topics: "موضوعات %{filter}"
with_category: "موضوعات %{filter} في %{category}" with_category: "موضوعات %{filter} في %{category}"
filter: filter:
title: "المرشح"
button: button:
label: "المرشح" label: "المرشح"
latest: latest:
@ -5770,6 +5769,7 @@ ar:
all: "الكل" all: "الكل"
new_features: new_features:
title: "ما الجديد" title: "ما الجديد"
check_for_updates: "تحقق من وجود تحديثات"
dashboard: dashboard:
title: "لوحة المعلومات" title: "لوحة المعلومات"
last_updated: "تم تحديث لوحة المعلومات:" last_updated: "تم تحديث لوحة المعلومات:"
@ -6409,6 +6409,7 @@ ar:
new_theme: "سمة جديدة" new_theme: "سمة جديدة"
user_selectable: "قابلة للاختيار من قِبل المستخدم" user_selectable: "قابلة للاختيار من قِبل المستخدم"
user_fields: user_fields:
field: "الحقل"
type: "النوع" type: "النوع"
more_options: more_options:
title: "المزيد من الخيارات" title: "المزيد من الخيارات"
@ -7633,6 +7634,7 @@ ar:
confirm_delete: "هل تريد بالتأكيد حذف سجل DiscourseConnect هذا؟" confirm_delete: "هل تريد بالتأكيد حذف سجل DiscourseConnect هذا؟"
user_fields: user_fields:
title: "حقول المستخدم" title: "حقول المستخدم"
add: "أضف حقل المستخدم"
untitled: "بلا لقب" untitled: "بلا لقب"
name: "اسم الحقل" name: "اسم الحقل"
type: "نوع الحقل" type: "نوع الحقل"
@ -7836,6 +7838,7 @@ ar:
none_selected: "تحديد شارة للبدء" none_selected: "تحديد شارة للبدء"
allow_title: السماح للمستخدمين باستخدام الشارة كعنوان allow_title: السماح للمستخدمين باستخدام الشارة كعنوان
multiple_grant: يمكن منحها عدة مرات multiple_grant: يمكن منحها عدة مرات
visibility_heading: الرؤية
listable: عرض الشارة في صفحة الشارات العامة listable: عرض الشارة في صفحة الشارات العامة
enabled: مفعَّلة enabled: مفعَّلة
disabled: متوقف disabled: متوقف

View File

@ -1279,7 +1279,6 @@ be:
categories_list: "Спіс катэгорый" categories_list: "Спіс катэгорый"
filters: filters:
filter: filter:
title: "Filter"
button: button:
label: "Filter" label: "Filter"
latest: latest:

View File

@ -3104,7 +3104,6 @@ bg:
with_topics: "%{filter} теми" with_topics: "%{filter} теми"
with_category: "%{filter} %{category} теми" with_category: "%{filter} %{category} теми"
filter: filter:
title: "Филтър"
button: button:
label: "Филтър" label: "Филтър"
latest: latest:
@ -4350,6 +4349,7 @@ bg:
none_selected: "Изберете значка за да започнете." none_selected: "Изберете значка за да започнете."
allow_title: Позволете значките да бъдат ползвани като титли. allow_title: Позволете значките да бъдат ползвани като титли.
multiple_grant: Може да бъде присъдена няколко пъти multiple_grant: Може да бъде присъдена няколко пъти
visibility_heading: Видимост
listable: Показвай значката на страницата със значки listable: Показвай значката на страницата със значки
enabled: да е включен enabled: да е включен
disabled: деактивирани disabled: деактивирани

View File

@ -2710,7 +2710,6 @@ bs_BA:
with_topics: "%{filter} teme" with_topics: "%{filter} teme"
with_category: "%{filter} %{category} teme" with_category: "%{filter} %{category} teme"
filter: filter:
title: "Filter"
button: button:
label: "Filter" label: "Filter"
latest: latest:
@ -4378,6 +4377,7 @@ bs_BA:
none_selected: "Izaberite značku za početak" none_selected: "Izaberite značku za početak"
allow_title: Allow badge to be used as a title allow_title: Allow badge to be used as a title
multiple_grant: Can be granted multiple times multiple_grant: Can be granted multiple times
visibility_heading: Vidljivost
listable: Show badge on the public badges page listable: Show badge on the public badges page
enabled: omogućen enabled: omogućen
disabled: deaktiviran disabled: deaktiviran

View File

@ -2619,7 +2619,6 @@ ca:
with_topics: "%{filter} temes" with_topics: "%{filter} temes"
with_category: "%{filter} %{category} temes" with_category: "%{filter} %{category} temes"
filter: filter:
title: "Filtre"
button: button:
label: "Filtre" label: "Filtre"
latest: latest:
@ -4236,6 +4235,7 @@ ca:
none_selected: "Tria una insígnia per a començar" none_selected: "Tria una insígnia per a començar"
allow_title: Permet que es faci servir la insígnia com a títol allow_title: Permet que es faci servir la insígnia com a títol
multiple_grant: Pot concedir-se moltes vegades multiple_grant: Pot concedir-se moltes vegades
visibility_heading: Visibilitat
listable: Mostra la insígnia en la pàgina pública d'insígnies listable: Mostra la insígnia en la pàgina pública d'insígnies
enabled: activat enabled: activat
disabled: desactivat disabled: desactivat

View File

@ -4310,7 +4310,6 @@ cs:
with_topics: "%{filter} témata" with_topics: "%{filter} témata"
with_category: "%{filter} %{category} témata" with_category: "%{filter} %{category} témata"
filter: filter:
title: "Filtr"
button: button:
label: "Filtr" label: "Filtr"
latest: latest:
@ -6540,6 +6539,7 @@ cs:
none_selected: "Vyberte odznak, abyste mohli začít" none_selected: "Vyberte odznak, abyste mohli začít"
allow_title: Povolit užití odzanku jako titul allow_title: Povolit užití odzanku jako titul
multiple_grant: Může být přiděleno několikrát multiple_grant: Může být přiděleno několikrát
visibility_heading: Viditelnost
listable: Zobrazit odznak na veřejné stránce s odzanky listable: Zobrazit odznak na veřejné stránce s odzanky
enabled: zapnuto enabled: zapnuto
disabled: vypnuto disabled: vypnuto

View File

@ -3310,7 +3310,6 @@ da:
with_topics: "%{filter} emner" with_topics: "%{filter} emner"
with_category: "%{filter} %{category} emner" with_category: "%{filter} %{category} emner"
filter: filter:
title: "Filter"
button: button:
label: "Filter" label: "Filter"
latest: latest:
@ -5355,6 +5354,7 @@ da:
none_selected: "Vælg et emblem for at komme igang" none_selected: "Vælg et emblem for at komme igang"
allow_title: Tillad at bruge dette emblem som titel allow_title: Tillad at bruge dette emblem som titel
multiple_grant: Kan tildeles flere gange multiple_grant: Kan tildeles flere gange
visibility_heading: Synlighed
listable: Vis emblemer på den offentlige emblem side listable: Vis emblemer på den offentlige emblem side
enabled: aktiveret enabled: aktiveret
disabled: deaktiveret disabled: deaktiveret

View File

@ -3546,6 +3546,7 @@ de:
other: "Zeige %{count} Antworten an" other: "Zeige %{count} Antworten an"
in_reply_to: "Übergeordneten Beitrag laden" in_reply_to: "Übergeordneten Beitrag laden"
view_all_posts: "Alle Beiträge anzeigen" view_all_posts: "Alle Beiträge anzeigen"
badge_granted_tooltip: "%{username} hat für diesen Beitrag das Abzeichen „%{badge_name}“ erhalten!"
errors: errors:
create: "Entschuldige, es gab einen Fehler beim Anlegen des Beitrags. Bitte versuche es noch einmal." create: "Entschuldige, es gab einen Fehler beim Anlegen des Beitrags. Bitte versuche es noch einmal."
edit: "Entschuldige, es gab einen Fehler beim Bearbeiten des Beitrags. Bitte versuche es noch einmal." edit: "Entschuldige, es gab einen Fehler beim Bearbeiten des Beitrags. Bitte versuche es noch einmal."
@ -4049,7 +4050,7 @@ de:
with_topics: "%{filter}" with_topics: "%{filter}"
with_category: "%{filter} in %{category}" with_category: "%{filter} in %{category}"
filter: filter:
title: "Filter" title: "Gefilterte Ergebnisse für %{filter}"
button: button:
label: "Filter" label: "Filter"
latest: latest:
@ -4771,6 +4772,7 @@ de:
all: "Gesamt" all: "Gesamt"
new_features: new_features:
title: "Was ist neu?" title: "Was ist neu?"
check_for_updates: "Nach Aktualisierungen suchen"
dashboard: dashboard:
title: "Dashboard" title: "Dashboard"
last_updated: "Dashboard aktualisiert:" last_updated: "Dashboard aktualisiert:"
@ -6265,6 +6267,7 @@ de:
invalid: "Entschuldige, du darfst nicht in die Rolle dieses Benutzers schlüpfen." invalid: "Entschuldige, du darfst nicht in die Rolle dieses Benutzers schlüpfen."
users: users:
title: "Benutzer" title: "Benutzer"
description: "Benutzer anzeigen und verwalten."
create: "Administrator hinzufügen" create: "Administrator hinzufügen"
last_emailed: "Letzte E-Mail" last_emailed: "Letzte E-Mail"
not_found: "Entschuldige, dieser Benutzername ist im System nicht vorhanden." not_found: "Entschuldige, dieser Benutzername ist im System nicht vorhanden."
@ -6785,9 +6788,13 @@ de:
no_user_badges: "%{name} wurden keine Abzeichen verliehen." no_user_badges: "%{name} wurden keine Abzeichen verliehen."
no_badges: Es gibt keine Abzeichen, die verliehen werden können. no_badges: Es gibt keine Abzeichen, die verliehen werden können.
none_selected: "Wähle ein Abzeichen aus, um loszulegen" none_selected: "Wähle ein Abzeichen aus, um loszulegen"
usage_heading: Verwendung
allow_title: Abzeichen darf als Titel verwendet werden allow_title: Abzeichen darf als Titel verwendet werden
multiple_grant: Kann mehrfach verliehen werden multiple_grant: Kann mehrfach verliehen werden
visibility_heading: Sichtbarkeit
listable: Zeige Abzeichen auf der öffentlichen Abzeichenseite an listable: Zeige Abzeichen auf der öffentlichen Abzeichenseite an
show_in_post_header: Abzeichen auf dem Beitrag anzeigen, für den es gewährt wurde
show_in_post_header_disabled: Erfordert, dass sowohl "Abzeichen auf der Seite mit den öffentlichen Abzeichen anzeigen" als auch "Abzeichen für Beiträge auf der Seite mit den Abzeichen anzeigen" aktiviert sind.
enabled: aktiviert enabled: aktiviert
disabled: deaktiviert disabled: deaktiviert
icon: Symbol icon: Symbol

View File

@ -3319,7 +3319,6 @@ el:
with_topics: "%{filter} θέματα" with_topics: "%{filter} θέματα"
with_category: "%{filter} %{category} θέματα" with_category: "%{filter} %{category} θέματα"
filter: filter:
title: "Φίλτρο"
button: button:
label: "Φίλτρο" label: "Φίλτρο"
latest: latest:
@ -5228,6 +5227,7 @@ el:
none_selected: "Διάλεξε ένα παράσημο για να ξεκινήσεις" none_selected: "Διάλεξε ένα παράσημο για να ξεκινήσεις"
allow_title: Το παράσημο μπορεί να χρησιμοποιηθεί σαν τίτλος allow_title: Το παράσημο μπορεί να χρησιμοποιηθεί σαν τίτλος
multiple_grant: Μπορεί να απονεμηθεί πολλές φορές multiple_grant: Μπορεί να απονεμηθεί πολλές φορές
visibility_heading: Ορατότητα
listable: Εμφάνιση του παράσημου στη δημόσια σελίδα παρασήμων listable: Εμφάνιση του παράσημου στη δημόσια σελίδα παρασήμων
enabled: ενεργοποιημένα enabled: ενεργοποιημένα
disabled: απενεργοποιημένα disabled: απενεργοποιημένα

View File

@ -3816,6 +3816,8 @@ en:
in_reply_to: "Load parent post" in_reply_to: "Load parent post"
view_all_posts: "View all posts" view_all_posts: "View all posts"
badge_granted_tooltip: "%{username} earned the '%{badge_name}' badge for this post!"
errors: errors:
create: "Sorry, there was an error creating your post. Please try again." create: "Sorry, there was an error creating your post. Please try again."
edit: "Sorry, there was an error editing your post. Please try again." edit: "Sorry, there was an error editing your post. Please try again."
@ -7195,9 +7197,13 @@ en:
no_user_badges: "%{name} has not been granted any badges." no_user_badges: "%{name} has not been granted any badges."
no_badges: There are no badges that can be granted. no_badges: There are no badges that can be granted.
none_selected: "Select a badge to get started" none_selected: "Select a badge to get started"
usage_heading: Usage
allow_title: Allow badge to be used as a title allow_title: Allow badge to be used as a title
multiple_grant: Can be granted multiple times multiple_grant: Can be granted multiple times
visibility_heading: Visibility
listable: Show badge on the public badges page listable: Show badge on the public badges page
show_in_post_header: Show badge on the post it was granted for
show_in_post_header_disabled: Requires both 'Show badge on the public badges page' and 'Show post granting badge on badge page' to be enabled.
enabled: enabled enabled: enabled
disabled: disabled disabled: disabled
icon: Icon icon: Icon

View File

@ -1492,12 +1492,12 @@ es:
download_backup_codes: "Descargar códigos de recuperación" download_backup_codes: "Descargar códigos de recuperación"
remaining_codes: remaining_codes:
one: "Te queda <strong>%{count}</strong> código de copia de seguridad sin usar todavía." one: "Te queda <strong>%{count}</strong> código de copia de seguridad sin usar todavía."
other: "Te quedan <strong>%{count}</strong> códigos de respaldo sin usar todavía." other: "Te quedan <strong>%{count}</strong> códigos de copia de seguridad sin usar todavía."
use: "Usar un código de copia de seguridad" use: "Usar un código de recuperación"
enable_prerequisites: "Debes activar un método principal de dos factores antes de generar códigos de respaldo." enable_prerequisites: "Debes activar un método principal de dos factores antes de generar código de copia de seguridad."
codes: codes:
title: "Códigos de respaldo generados" title: "Códigos de copia de seguridad generados"
description: "Cada uno de estos códigos de respaldo se puede usar una única vez. Mantenlos en un lugar seguro pero accesible." description: "Cada uno de estos códigos de copia de seguridad se puede usar una única vez. Mantenlos en un lugar seguro pero accesible."
second_factor: second_factor:
title: "Autenticación de dos factores" title: "Autenticación de dos factores"
enable: "Gestionar la autenticación de dos factores" enable: "Gestionar la autenticación de dos factores"
@ -1514,7 +1514,7 @@ es:
extended_description: | extended_description: |
La autenticación de dos factores añade una capa extra de seguridad a tu cuenta al pedirte un código de un solo uso además de tu contraseña. Los códigos se pueden generar en dispositivos <a href="https://www.google.com/search?q=authenticator+apps+for+android" target='_blank'>Android</a> e <a href="https://www.google.com/search?q=authenticator+apps+for+ios">iOS</a>. La autenticación de dos factores añade una capa extra de seguridad a tu cuenta al pedirte un código de un solo uso además de tu contraseña. Los códigos se pueden generar en dispositivos <a href="https://www.google.com/search?q=authenticator+apps+for+android" target='_blank'>Android</a> e <a href="https://www.google.com/search?q=authenticator+apps+for+ios">iOS</a>.
oauth_enabled_warning: "Ten en cuenta que los inicios de sesión con redes sociales y páginas de terceros se desactivarán para tu cuenta una vez que actives la autenticación de dos factores." oauth_enabled_warning: "Ten en cuenta que los inicios de sesión con redes sociales y páginas de terceros se desactivarán para tu cuenta una vez que actives la autenticación de dos factores."
use: "Utilizar la aplicación de autenticación" use: "Usar la aplicación de autenticación"
enforced_with_oauth_notice: "Debes activar la autenticación de dos factores. Solo se te pedirá que la utilices cuando inicies sesión con una contraseña, no con métodos de autenticación externa o de inicio de sesión con red social." enforced_with_oauth_notice: "Debes activar la autenticación de dos factores. Solo se te pedirá que la utilices cuando inicies sesión con una contraseña, no con métodos de autenticación externa o de inicio de sesión con red social."
enforced_notice: "Es obligatorio que actives la autenticación de dos factores antes de acceder al sitio web." enforced_notice: "Es obligatorio que actives la autenticación de dos factores antes de acceder al sitio web."
disable: "Desactivar" disable: "Desactivar"
@ -1655,8 +1655,8 @@ es:
confirm_modal_title: "Conectar cuenta de %{provider}" confirm_modal_title: "Conectar cuenta de %{provider}"
confirm_description: confirm_description:
disconnect: "Tu cuenta de %{provider} «%{account_description}» será desenlazada" disconnect: "Tu cuenta de %{provider} «%{account_description}» será desenlazada"
account_specific: "Tu cuenta de %{provider} «%{account_description}» se utilizará para la autenticación." account_specific: "Tu cuenta de %{provider} «%{account_description}» se usará para la autenticación."
generic: "Tu cuenta de %{provider} se utilizará para la autenticación." generic: "Tu cuenta de %{provider} se usará para la autenticación."
activate_account: activate_account:
action: "Haz clic aquí para activar tu cuenta" action: "Haz clic aquí para activar tu cuenta"
already_done: "Lo sentimos, este enlace de confirmación de cuenta ya no es válido. ¿Quizás tu cuenta ya está activa?" already_done: "Lo sentimos, este enlace de confirmación de cuenta ya no es válido. ¿Quizás tu cuenta ya está activa?"
@ -1698,7 +1698,7 @@ es:
title: "Código de invitación" title: "Código de invitación"
instructions: "El registro de la cuenta requiere un código de invitación" instructions: "El registro de la cuenta requiere un código de invitación"
auth_tokens: auth_tokens:
title: "Dispositivos utilizados recientemente" title: "Dispositivos usados recientemente"
short_description: "Esta es una lista de los dispositivos que se han conectado recientemente a tu cuenta." short_description: "Esta es una lista de los dispositivos que se han conectado recientemente a tu cuenta."
details: "Detalles" details: "Detalles"
log_out_all: "Cerrar sesión en todos los dispositivos" log_out_all: "Cerrar sesión en todos los dispositivos"
@ -2235,7 +2235,7 @@ es:
second_factor_description: "Introduce el código de autenticación desde tu aplicación:" second_factor_description: "Introduce el código de autenticación desde tu aplicación:"
second_factor_backup: "Iniciar sesión utilizando un código de copia de seguridad" second_factor_backup: "Iniciar sesión utilizando un código de copia de seguridad"
second_factor_backup_title: "Código de respaldo de la autenticación de dos factores" second_factor_backup_title: "Código de respaldo de la autenticación de dos factores"
second_factor_backup_description: "Introduce uno de los códigos de respaldo:" second_factor_backup_description: "Introduce uno de los códigos de copia de seguridad:"
second_factor: "Iniciar sesión utilizando la aplicación de autenticación" second_factor: "Iniciar sesión utilizando la aplicación de autenticación"
security_key_description: "Cuando tengas tu clave de seguridad física o tu dispositivo móvil compatible preparado, presiona el botón de autenticar con clave de seguridad que se encuentra debajo." security_key_description: "Cuando tengas tu clave de seguridad física o tu dispositivo móvil compatible preparado, presiona el botón de autenticar con clave de seguridad que se encuentra debajo."
security_key_alternative: "Probar de otra manera" security_key_alternative: "Probar de otra manera"
@ -2794,13 +2794,13 @@ es:
aria_label: Filtrar por etiqueta aria_label: Filtrar por etiqueta
filters: filters:
label: Solo temas/publicaciones que… label: Solo temas/publicaciones que…
title: coincide el título únicamente title: Coincide solo en el título
likes: me han gustado likes: me han gustado
posted: he publicado en ellos posted: he publicado en ellos
created: Creado por mi created: Creado por mi
watching: estoy vigilando watching: estoy vigilando
tracking: estoy siguiendo tracking: estoy siguiendo
private: en mis mensajes private: En mis mensajes
bookmarks: he guardado bookmarks: he guardado
first: son la primera publicación first: son la primera publicación
pinned: están anclados pinned: están anclados
@ -4047,7 +4047,6 @@ es:
with_topics: "%{filter} temas" with_topics: "%{filter} temas"
with_category: "%{filter} Foro de %{category}" with_category: "%{filter} Foro de %{category}"
filter: filter:
title: "Filtrar"
button: button:
label: "Filtrar" label: "Filtrar"
latest: latest:
@ -4326,8 +4325,8 @@ es:
one: 'Esta etiqueta pertence al grupo: «%{tag_groups}»' one: 'Esta etiqueta pertence al grupo: «%{tag_groups}»'
other: "Esta etiqueta pertence a estos grupos: %{tag_groups}." other: "Esta etiqueta pertence a estos grupos: %{tag_groups}."
category_restrictions: category_restrictions:
one: "Solo se puede utilizar en esta categoría:" one: "Solo se puede usar en esta categoría:"
other: "Solo se puede utilizar en estas categorías:" other: "Solo se puede usar en estas categorías:"
edit_synonyms: "Editar sinónimos" edit_synonyms: "Editar sinónimos"
add_synonyms_label: "Añadir sinónimos:" add_synonyms_label: "Añadir sinónimos:"
add_synonyms: "Añadir" add_synonyms: "Añadir"
@ -5151,7 +5150,7 @@ es:
reviewable_created: "El elemento revisable está listo" reviewable_created: "El elemento revisable está listo"
reviewable_updated: "Se ha actualizado el elemento revisable" reviewable_updated: "Se ha actualizado el elemento revisable"
user_badge_event: user_badge_event:
group_name: "Eventos de Medalla" group_name: "Eventos de medalla"
user_badge_granted: "Se ha concedido la medalla de usuario" user_badge_granted: "Se ha concedido la medalla de usuario"
user_badge_revoked: "Se ha revocado la medalla de usuario" user_badge_revoked: "Se ha revocado la medalla de usuario"
like_event: like_event:
@ -6417,7 +6416,7 @@ es:
<p><b>¡Esto no se puede deshacer!</b></p> <p><b>¡Esto no se puede deshacer!</b></p>
<p>Para continuar escribe: <code>%{text}</code></p> <p>Para continuar, escribe: <code>%{text}</code></p>
text: "eliminar publicaciones de @%{username}" text: "eliminar publicaciones de @%{username}"
delete: "Eliminar publicaciones de @%{username}" delete: "Eliminar publicaciones de @%{username}"
cancel: "Cancelar" cancel: "Cancelar"
@ -6742,10 +6741,11 @@ es:
granted_badges: Medallas concedidas granted_badges: Medallas concedidas
grant: Conceder grant: Conceder
no_user_badges: "%{name} no ha recibido ninguna medalla." no_user_badges: "%{name} no ha recibido ninguna medalla."
no_badges: No hay medallas que puedan ser concedidas. no_badges: No hay medallas que se puedan conceder.
none_selected: "Selecciona una medalla para empezar" none_selected: "Selecciona una medalla para empezar"
allow_title: Permitir que se use la medalla como título allow_title: Permitir que se use la medalla como título
multiple_grant: Se puede conceder varias veces multiple_grant: Se puede conceder varias veces
visibility_heading: Visibilidad
listable: Mostrar medalla en la página pública de medallas listable: Mostrar medalla en la página pública de medallas
enabled: activado enabled: activado
disabled: desactivado disabled: desactivado
@ -6788,8 +6788,8 @@ es:
with_post_time: <span class="username">%{username}</span> por la publicación en %{link} a las <span class="time">%{time}</span> with_post_time: <span class="username">%{username}</span> por la publicación en %{link} a las <span class="time">%{time}</span>
with_time: <span class="username">%{username}</span> a las <span class="time">%{time}</span> with_time: <span class="username">%{username}</span> a las <span class="time">%{time}</span>
badge_intro: badge_intro:
title: "Elige una insignia o crea una nueva" title: "Elige una medalla o crea una nueva"
description: "Empieza seleccionando una insignia existente para personalizarla, o crea una insignia totalmente nueva" description: "Empieza seleccionando una medalla existente para personalizarla, o crea una medalla totalmente nueva"
mass_award: mass_award:
title: Conceder en masa title: Conceder en masa
description: Concede la misma medalla a muchos usuarios a la vez. description: Concede la misma medalla a muchos usuarios a la vez.

View File

@ -2362,7 +2362,6 @@ et:
with_topics: "%{filter} teemat" with_topics: "%{filter} teemat"
with_category: "%{filter} %{category} teemat" with_category: "%{filter} %{category} teemat"
filter: filter:
title: "Filter"
button: button:
label: "Filter" label: "Filter"
latest: latest:
@ -3685,6 +3684,7 @@ et:
none_selected: "Vali märgis, et alustada" none_selected: "Vali märgis, et alustada"
allow_title: Luba märgise kasutamist tiitlina allow_title: Luba märgise kasutamist tiitlina
multiple_grant: Võib olla määratud mitmeid kordi multiple_grant: Võib olla määratud mitmeid kordi
visibility_heading: Nähtavus
listable: Näita märgist avalike märgiste lehel listable: Näita märgist avalike märgiste lehel
enabled: sisse lülitatud enabled: sisse lülitatud
disabled: välja lülitatud disabled: välja lülitatud

View File

@ -753,12 +753,17 @@ fa_IR:
prefill: prefill:
title: "از قبل پر کردن با تنظیمات برای:" title: "از قبل پر کردن با تنظیمات برای:"
gmail: "جی‌میل" gmail: "جی‌میل"
outlook: "Outlook.com"
office365: "مایکروسافت ۳۶۵"
ssl_modes: ssl_modes:
none: "هیچ کدام" none: "هیچ کدام"
ssl_tls: "SSL/TLS"
starttls: "STARTTLS"
credentials: credentials:
title: "اطلاعات ورود" title: "اطلاعات ورود"
smtp_server: "سرور SMTP" smtp_server: "سرور SMTP"
smtp_port: "درگاه SMTP" smtp_port: "درگاه SMTP"
smtp_ssl_mode: "حالت SSL"
imap_server: "سرور IMAP" imap_server: "سرور IMAP"
imap_port: "درگاه IMAP" imap_port: "درگاه IMAP"
imap_ssl: "از SSL برای IMAP استفاده کنید" imap_ssl: "از SSL برای IMAP استفاده کنید"
@ -3134,7 +3139,6 @@ fa_IR:
with_topics: "%{filter} موضوعات" with_topics: "%{filter} موضوعات"
with_category: "%{filter} %{category} موضوعات" with_category: "%{filter} %{category} موضوعات"
filter: filter:
title: "فیلتر"
button: button:
label: "فیلتر" label: "فیلتر"
latest: latest:
@ -4748,6 +4752,7 @@ fa_IR:
none_selected: "برای شروع یک نشان رو انتخاب کنید" none_selected: "برای شروع یک نشان رو انتخاب کنید"
allow_title: اجازه‌ی استفاده نشان برای عنوان allow_title: اجازه‌ی استفاده نشان برای عنوان
multiple_grant: نمی توان چندین بار اعطا کرد multiple_grant: نمی توان چندین بار اعطا کرد
visibility_heading: قابل مشاهده
listable: نشان دادن نشان در صفحه نشان‌های عمومی listable: نشان دادن نشان در صفحه نشان‌های عمومی
enabled: فعال شده enabled: فعال شده
disabled: غیرفعال شده disabled: غیرفعال شده

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