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

View File

@ -27,6 +27,7 @@ const FORM_FIELDS = [
"badge_grouping_id",
"trigger",
"badge_type_id",
"show_in_post_header",
];
export default class AdminBadgesShowController extends Controller {
@ -40,8 +41,6 @@ export default class AdminBadgesShowController extends Controller {
@tracked model;
@tracked previewLoading = false;
@tracked selectedGraphicType = null;
@tracked userBadges;
@tracked userBadgesAll;
@cached
get formData() {
@ -80,6 +79,17 @@ export default class AdminBadgesShowController extends Controller {
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() {
// 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.

View File

@ -245,7 +245,7 @@
</field.Menu>
</form.Field>
<form.CheckboxGroup as |group|>
<form.CheckboxGroup @title={{i18n "admin.badges.usage_heading"}} as |group|>
<group.Field
@title={{i18n "admin.badges.allow_title"}}
@showTitle={{false}}
@ -264,7 +264,12 @@
>
<field.Checkbox />
</group.Field>
</form.CheckboxGroup>
<form.CheckboxGroup
@title={{i18n "admin.badges.visibility_heading"}}
as |group|
>
<group.Field
@title={{i18n "admin.badges.listable"}}
@showTitle={{false}}
@ -284,6 +289,20 @@
>
<field.Checkbox />
</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.Section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import Component from "@ember/component";
import { tagName } from "@ember-decorators/component";
import { applyValueTransformer } from "discourse/lib/transformer";
import discourseComputed from "discourse-common/utils/decorators";
const LIST_TYPE = {
@ -40,4 +41,8 @@ export default class CategoryListItem extends Component {
slugPath(categoryPath) {
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"}}
@topicId={{this.composer.model.topic.id}}
@categoryId={{this.composer.model.category.id}}
@onSetup={{this.setupEditor}}
>
{{yield}}
</DEditor>

View File

@ -18,7 +18,6 @@ import {
linkSeenMentions,
} from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import {
authorizesOneOrMoreImageExtensions,
IMAGE_MARKDOWN_REGEX,
@ -139,7 +138,7 @@ export default class ComposerEditor extends Component {
@observes("composer.focusTarget")
setFocus() {
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")
_composerEditorInit() {
const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper");
input?.addEventListener(
"scroll",
this._throttledSyncEditorAndPreviewScroll
);
this._registerImageAltTextButtonClick(preview);
// Focus on the body unless we have a title
if (!this.get("composer.model.canEditTitle")) {
putCursorAtEnd(input);
}
if (this.composer.allowUpload) {
this.uppyComposerUpload.setup(this.element);
}
@ -210,6 +197,30 @@ export default class ComposerEditor extends Component {
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(
"composer.model.reply",
"composer.model.replyLength",
@ -785,7 +796,6 @@ export default class ComposerEditor extends Component {
@on("willDestroyElement")
_composerClosed() {
const input = this.element.querySelector(".d-editor-input");
const preview = this.element.querySelector(".d-editor-preview-wrapper");
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._handleAltTextEditButtonClick);
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 { loadOneboxes } from "discourse/lib/load-oneboxes";
import { emojiUrlFor, generateCookFunction } from "discourse/lib/text";
import { getHead } from "discourse/lib/textarea-text-manipulation";
import userSearch from "discourse/lib/user-search";
import {
destroyUserStatuses,
@ -36,8 +35,6 @@ import { findRawTemplate } from "discourse-common/lib/raw-templates";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
const FOUR_SPACES_INDENT = "4-spaces-indent";
let _createCallbacks = [];
export function addToolbarCallback(func) {
@ -145,8 +142,6 @@ export default class DEditor extends Component {
keymap["tab"] = () => this.textManipulation.indentSelection("right");
keymap["shift+tab"] = () => this.textManipulation.indentSelection("left");
keymap[`${PLATFORM_KEY_MODIFIER}+shift+.`] = () =>
this.send("insertCurrentTime");
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
rovingButtonBar(event) {
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) {
const selected = this.textManipulation.getSelected(trimLeading);
return {
@ -574,8 +562,8 @@ export default class DEditor extends Component {
opts
),
applyList: (head, exampleKey, opts) =>
this._applyList(selected, head, exampleKey, opts),
formatCode: (...args) => this.send("formatCode", args),
this.textManipulation.applyList(selected, head, exampleKey, opts),
formatCode: () => this.textManipulation.formatCode(),
addText: (text) => this.textManipulation.addText(selected, text),
getText: () => this.value,
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
handleFocusIn() {
this.set("isEditorFocused", true);
@ -715,6 +638,8 @@ export default class DEditor extends Component {
this._applyHashtagAutocomplete();
this._applyMentionAutocomplete();
const destroyEditor = this.onSetup?.(textManipulation);
scheduleOnce("afterRender", this, this._readyNow);
return () => {
@ -723,6 +648,8 @@ export default class DEditor extends Component {
this.element?.removeEventListener("paste", textManipulation.paste);
textManipulation.autocomplete("destroy");
destroyEditor?.();
};
}
@ -741,7 +668,11 @@ export default class DEditor extends Component {
textManipulation,
"replaceText"
);
this.appEvents.on("composer:apply-surround", this, "_applySurround");
this.appEvents.on(
"composer:apply-surround",
textManipulation,
"applySurroundSelection"
);
this.appEvents.on(
"composer:indent-selected-text",
textManipulation,
@ -764,7 +695,11 @@ export default class DEditor extends Component {
textManipulation,
"replaceText"
);
this.appEvents.off("composer:apply-surround", this, "_applySurround");
this.appEvents.off(
"composer:apply-surround",
textManipulation,
"applySurroundSelection"
);
this.appEvents.off(
"composer:indent-selected-text",
textManipulation,

View File

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

View File

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

View File

@ -35,7 +35,7 @@ export default class PostMenuEditButton extends Component {
}}
...attributes
@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"}}
@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}}>
{{avatar this.post imageSize="large"}}
</a>
</div>
</div>
</PluginOutlet>
<div class="fps-topic" data-topic-id={{this.post.topic.id}}>
<div class="topic">
{{#if this.bulkSelectEnabled}}
<TrackSelected
@selectedList={{this.selected}}
@ -55,6 +62,10 @@
</div>
</div>
<PluginOutlet
@name="search-result-entry-blurb-wrapper"
@outletArgs={{hash post=this.post logClick=this.logClick}}
>
<div class="blurb container">
<span class="date">
{{format-date this.post.created_at format="tiny"}}
@ -73,7 +84,12 @@
{{/if}}
{{/if}}
</div>
</PluginOutlet>
<PluginOutlet
@name="search-result-entry-stats-wrapper"
@outletArgs={{hash post=this.post}}
>
{{#if this.showLikeCount}}
{{#if this.post.like_count}}
<span class="like-count">
@ -82,6 +98,7 @@
</span>
{{/if}}
{{/if}}
</PluginOutlet>
</div>
<PluginOutlet @name="after-search-result-entry" />

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { next, schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import $ from "jquery";
import putCursorAtEnd from "discourse/lib/put-cursor-at-end";
import { generateLinkifyFunction } from "discourse/lib/text";
import { siteDir } from "discourse/lib/text-direction";
import toMarkdown from "discourse/lib/to-markdown";
@ -28,6 +29,8 @@ const OP = {
ADDED: 2,
};
const FOUR_SPACES_INDENT = "4-spaces-indent";
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
export function getHead(head, prev) {
@ -42,6 +45,7 @@ export default class TextareaTextManipulation {
@service appEvents;
@service siteSettings;
@service capabilities;
@service currentUser;
eventPrefix;
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) {
const pre = sel.pre;
const post = sel.post;
@ -730,6 +738,7 @@ export default class TextareaTextManipulation {
}
}
@bind
async inCodeBlock() {
return inCodeBlock(
this.$textarea.value ?? this.$textarea.val(),
@ -737,6 +746,7 @@ export default class TextareaTextManipulation {
);
}
@bind
toggleDirection() {
let currentDir = this.$textarea.attr("dir")
? this.$textarea.attr("dir")
@ -746,6 +756,75 @@ export default class TextareaTextManipulation {
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() {
return this.$textarea.autocomplete(...arguments);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,10 @@
<PluginOutlet
@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"}}

View File

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

View File

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

View File

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

View File

@ -15,8 +15,8 @@
"test": "ember test"
},
"dependencies": {
"@faker-js/faker": "^9.2.0",
"@glimmer/syntax": "^0.92.3",
"@faker-js/faker": "^9.3.0",
"@glimmer/syntax": "^0.93.1",
"@highlightjs/cdn-assets": "^11.10.0",
"@json-editor/json-editor": "2.15.2",
"@messageformat/core": "^3.4.0",
@ -56,7 +56,7 @@
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
"@popperjs/core": "^2.11.8",
"@swc/core": "^1.9.3",
"@swc/core": "^1.10.0",
"@types/jquery": "^3.5.32",
"@types/qunit": "^2.19.12",
"@types/rsvp": "^4.0.9",
@ -107,13 +107,13 @@
"imports-loader": "^5.0.0",
"jquery": "^3.7.1",
"js-yaml": "^4.1.0",
"jsuites": "^5.7.2",
"jsuites": "^5.8.0",
"loader.js": "^4.7.0",
"make-plural": "^7.4.0",
"message-bus-client": "^4.3.8",
"pretender": "^3.4.7",
"qunit": "^2.22.0",
"qunit-dom": "^3.3.0",
"qunit": "^2.23.0",
"qunit-dom": "^3.4.0",
"sass": "^1.77.7",
"select-kit": "workspace:1.0.0",
"sinon": "^19.0.2",
@ -123,7 +123,7 @@
"truth-helpers": "workspace:1.0.0",
"util": "^0.12.5",
"virtual-dom": "^2.1.1",
"webpack": "^5.96.1",
"webpack": "^5.97.0",
"webpack-retry-chunk-load-plugin": "^3.1.1",
"webpack-stats-plugin": "^1.1.3",
"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 {
acceptance,
query,
selectText,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
@ -127,7 +126,7 @@ acceptance("Composer Actions", function (needs) {
assert.strictEqual(categoryChooserReplyArea.header().name(), "faq");
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) {
@ -210,7 +209,7 @@ acceptance("Composer Actions", function (needs) {
await composerActions.expand();
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(1).value(), "reply_to_topic");
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 click(".topic-post:nth-of-type(1) button.reply");
await fillIn(
".d-editor-input",
"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 click(".topic-post:nth-of-type(1) button.show-more-actions");
await click(".topic-post:nth-of-type(1) button.edit");
@ -31,6 +31,6 @@ acceptance("Composer - Edit conflict", function (needs) {
".d-editor-input",
"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 { test } from "qunit";
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) {
needs.user({});
@ -339,10 +339,9 @@ acceptance("Composer - Image Preview", function (needs) {
.dom(".d-editor-input")
.hasValue(uploads.join("\n"), "Image should be removed from the editor");
assert.false(
query(".d-editor-input").value.includes("image_example_0"),
"does not have the first image"
);
assert
.dom(".d-editor-input")
.doesNotIncludeValue("image_example_0", "does not have the first image");
assert
.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 click(".topic-post:nth-of-type(1) button.edit");
@ -586,8 +586,10 @@ acceptance("Composer", function (needs) {
/^This is the first post\./,
"populates the input with the post text"
);
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");
assert
.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) {
const menu = selectKit(".toolbar-popup-menu-options");
@ -802,6 +825,7 @@ acceptance("Composer", function (needs) {
i18n("post.cancel_composer.keep_editing"),
"has keep editing button"
);
await click(".d-modal__footer button.save-draft");
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.true(
query(".d-editor-input").value.includes(longText),
"entered text should still be there"
);
assert
.dom(".d-editor-input")
.includesValue(longText, "entered text should still be there");
assert
.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
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) {
needs.user();
needs.settings({

View File

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

View File

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

View File

@ -13,7 +13,6 @@ import {
acceptance,
chromeTest,
publishToMessageBus,
query,
selectText,
} from "discourse/tests/helpers/qunit-helpers";
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 click(".quote-button .insert-quote");
assert.true(
query(".d-editor-input").value.includes(
'quote="codinghorror said, post:3, topic:280"'
)
);
assert
.dom(".d-editor-input")
.includesValue('quote="codinghorror said, post:3, topic:280"');
});
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 click(".quote-button .insert-quote");
assert.true(
query(".d-editor-input").value.includes(
assert
.dom(".d-editor-input")
.includesValue(
'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 click(".reply");
assert.true(
query(".d-editor-input").value.includes(
'quote="codinghorror said, post:3, topic:280"'
)
);
assert
.dom(".d-editor-input")
.includesValue('quote="codinghorror said, post:3, topic:280"');
});
// 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", "T");
assert.true(
query(".d-editor-input").value.includes(
'quote="codinghorror said, post:3, topic:280"'
)
);
assert
.dom(".d-editor-input")
.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 selectText("#post_5 .cooked");
await click(".quote-button .insert-quote");
assert.true(
query(".d-editor-input").value.includes(
'quote="pekka, post:5, topic:280, full:true"'
)
);
assert
.dom(".d-editor-input")
.includesValue('quote="pekka, post:5, topic:280, full:true"');
});
});

View File

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

View File

@ -77,4 +77,22 @@ module("Integration | Component | badge-button", function (hooks) {
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);
});
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) {
this.set("value", "");
@ -615,6 +635,22 @@ third line`
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) {
this.siteSettings.support_mixed_text_direction = true;
this.siteSettings.default_locale = "en";

View File

@ -31,7 +31,7 @@ module("Integration | Component | select-kit/tag-drop", function (hooks) {
await render(<template>
<TagDrop
@currentCategory={{category}}
@tagId={{"jeff"}}
@tagId="jeff"
@options={{hash tagId="jeff"}}
/>
</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 { hbs } from "ember-cli-htmlbars";
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";
module("Integration | Component | Widget | poster-name", function (hooks) {
@ -70,4 +72,38 @@ module("Integration | Component | Widget | poster-name", function (hooks) {
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 { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
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() {
return `d-menu-${this.menuInstance.id}`;
}
@ -73,6 +98,7 @@ export default class DMenu extends Component {
@translatedTitle={{@title}}
@disabled={{@disabled}}
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
...attributes
>
{{#if (has-block "trigger")}}
@ -122,6 +148,7 @@ export default class DMenu extends Component {
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}
{{didInsert this.registerFloatBody}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}

View File

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

View File

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

View File

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

View File

@ -1104,6 +1104,7 @@ a.inline-editable-field {
@import "common/admin/site-settings";
@import "common/admin/admin_config_area";
@import "common/admin/admin_table";
@import "common/admin/admin_filter";
@import "common/admin/admin_reports";
@import "common/admin/admin_report";
@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);
}
a > code {
padding: 2px 4px;
background: var(--inline-code-bg);
white-space: pre-wrap;
color: var(--tertiary);
}
code {
color: var(--primary-very-high);
background: var(--hljs-bg);

View File

@ -257,6 +257,24 @@
.names {
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 {
@ -265,8 +283,9 @@
align-items: center;
}
.user-status-message {
.user-status-message-wrap {
display: flex;
flex-shrink: 0;
img.emoji {
width: 1em;

View File

@ -249,9 +249,6 @@
.title-wrapper {
display: flex;
flex-wrap: wrap;
button {
margin: 0 0.5em 0 0;
}
.topic-statuses {
line-height: 1.2;
.d-icon {
@ -268,7 +265,9 @@
.title-wrapper {
display: flex;
flex-wrap: wrap;
width: 90%;
@media screen and (min-width: 925px) {
width: 90%; // topic title isn't full-width on wide screens
}
}
h1 {
margin-bottom: 0;
@ -282,36 +281,66 @@
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
gap: 0.5em;
width: 100%;
max-width: calc(
var(--topic-body-width) + (var(--topic-body-width-padding) * 2) +
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 {
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 {

View File

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

View File

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

View File

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

View File

@ -1,8 +1,24 @@
// Shared styles
.login-page,
.signup-page,
.invite-page {
#main-outlet,
#main-outlet-wrapper {
padding: 0;
}
}
.login-fullpage,
.signup-fullpage,
.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,
.login-body {
flex-direction: column;

View File

@ -49,6 +49,10 @@ class Admin::SiteSettingsController < Admin::AdminController
on_failed_policy(:setting_is_visible) do
raise Discourse::InvalidParameters, I18n.t("errors.site_settings.site_setting_is_hidden")
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
raise Discourse::InvalidParameters,
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)
if data.present?
# this is a bit of a kludge we need to remove (all the parsing) too many special cases here
# we need to catch action edit and action editSharedDraft
if data["postId"].present? && data["originalText"].present? &&
data["action"].to_s.start_with?("edit")
post = Post.find_by(id: data["postId"])
if post && post.raw != data["originalText"]
# check for conflicts when editing a post
if data.present? && data["postId"].present? && data["action"].to_s.start_with?("edit")
original_text = data["original_text"] || data["originalText"]
original_title = data["original_title"]
original_tags = data["original_tags"]
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)
render json: json.merge(conflict_user: conflict_user)
return
json.merge!(conflict_user:)
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] }
raw_old = params[:post][:raw_old]
if raw_old.present? && raw_old != post.raw
# keep `raw_old` for backwards compatibility
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)
end

View File

@ -359,8 +359,19 @@ class TopicsController < ApplicationController
def update
topic = Topic.find_by(id: params[:topic_id])
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 topic.shared_draft
topic.shared_draft.update(category_id: params[:category_id])

View File

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

View File

@ -327,6 +327,6 @@ class Emoji
end
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

View File

@ -658,6 +658,7 @@ class Post < ActiveRecord::Base
"flag_reasons.#{post_action_type_view.types[post_action_type_id]}",
locale: SiteSetting.default_locale,
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
destination_topic.reload
DiscourseEvent.trigger(
:posts_moved,
destination_topic_id: destination_topic.id,
original_topic_id: original_topic.id,
)
destination_topic
end
@ -299,6 +304,7 @@ class PostMover
end
moved_post.attributes = update
moved_post.disable_rate_limits! if @options[:freeze_original]
moved_post.save(validate: false)
DiscourseEvent.trigger(:post_moved, moved_post, original_topic.id)

View File

@ -35,6 +35,19 @@ class UserBadge < ActiveRecord::Base
scope :for_enabled_badges,
-> { 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 :user_id, presence: true

View File

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

View File

@ -38,6 +38,7 @@ class PostSerializer < BasicPostSerializer
:flair_bg_color,
:flair_color,
:flair_group_id,
:badges_granted,
:version,
:can_edit,
:can_delete,
@ -223,6 +224,18 @@ class PostSerializer < BasicPostSerializer
object.user&.flair_group_id
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
return @single_post_link_counts if @single_post_link_counts.present?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@
<%- if can_sign_up? %>
<a href="<%= path "/signup"%>" class='btn btn-primary btn-small sign-up-button'><%= I18n.t('sign_up') %></a>
<%- 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>
<%- end %>
</div>

View File

@ -12,7 +12,7 @@
<h1 class="title"><%= @title %></h1>
<%- 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 %>
<%- if @group&.dig(:allow_membership_requests) %>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4310,7 +4310,6 @@ cs:
with_topics: "%{filter} témata"
with_category: "%{filter} %{category} témata"
filter:
title: "Filtr"
button:
label: "Filtr"
latest:
@ -6540,6 +6539,7 @@ cs:
none_selected: "Vyberte odznak, abyste mohli začít"
allow_title: Povolit užití odzanku jako titul
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
enabled: zapnuto
disabled: vypnuto

View File

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

View File

@ -3546,6 +3546,7 @@ de:
other: "Zeige %{count} Antworten an"
in_reply_to: "Übergeordneten Beitrag laden"
view_all_posts: "Alle Beiträge anzeigen"
badge_granted_tooltip: "%{username} hat für diesen Beitrag das Abzeichen „%{badge_name}“ erhalten!"
errors:
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."
@ -4049,7 +4050,7 @@ de:
with_topics: "%{filter}"
with_category: "%{filter} in %{category}"
filter:
title: "Filter"
title: "Gefilterte Ergebnisse für %{filter}"
button:
label: "Filter"
latest:
@ -4771,6 +4772,7 @@ de:
all: "Gesamt"
new_features:
title: "Was ist neu?"
check_for_updates: "Nach Aktualisierungen suchen"
dashboard:
title: "Dashboard"
last_updated: "Dashboard aktualisiert:"
@ -6265,6 +6267,7 @@ de:
invalid: "Entschuldige, du darfst nicht in die Rolle dieses Benutzers schlüpfen."
users:
title: "Benutzer"
description: "Benutzer anzeigen und verwalten."
create: "Administrator hinzufügen"
last_emailed: "Letzte E-Mail"
not_found: "Entschuldige, dieser Benutzername ist im System nicht vorhanden."
@ -6785,9 +6788,13 @@ de:
no_user_badges: "%{name} wurden keine Abzeichen verliehen."
no_badges: Es gibt keine Abzeichen, die verliehen werden können.
none_selected: "Wähle ein Abzeichen aus, um loszulegen"
usage_heading: Verwendung
allow_title: Abzeichen darf als Titel verwendet werden
multiple_grant: Kann mehrfach verliehen werden
visibility_heading: Sichtbarkeit
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
disabled: deaktiviert
icon: Symbol

View File

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

View File

@ -3816,6 +3816,8 @@ en:
in_reply_to: "Load parent post"
view_all_posts: "View all posts"
badge_granted_tooltip: "%{username} earned the '%{badge_name}' badge for this post!"
errors:
create: "Sorry, there was an error creating 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_badges: There are no badges that can be granted.
none_selected: "Select a badge to get started"
usage_heading: Usage
allow_title: Allow badge to be used as a title
multiple_grant: Can be granted multiple times
visibility_heading: Visibility
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
disabled: disabled
icon: Icon

View File

@ -1492,12 +1492,12 @@ es:
download_backup_codes: "Descargar códigos de recuperación"
remaining_codes:
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."
use: "Usar un código de copia de seguridad"
enable_prerequisites: "Debes activar un método principal de dos factores antes de generar códigos de respaldo."
other: "Te quedan <strong>%{count}</strong> códigos de copia de seguridad sin usar todavía."
use: "Usar un código de recuperación"
enable_prerequisites: "Debes activar un método principal de dos factores antes de generar código de copia de seguridad."
codes:
title: "Códigos de respaldo generados"
description: "Cada uno de estos códigos de respaldo se puede usar una única vez. Mantenlos en un lugar seguro pero accesible."
title: "Códigos de copia de seguridad generados"
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:
title: "Autenticación de dos factores"
enable: "Gestionar la autenticación de dos factores"
@ -1514,7 +1514,7 @@ es:
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>.
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_notice: "Es obligatorio que actives la autenticación de dos factores antes de acceder al sitio web."
disable: "Desactivar"
@ -1655,8 +1655,8 @@ es:
confirm_modal_title: "Conectar cuenta de %{provider}"
confirm_description:
disconnect: "Tu cuenta de %{provider} «%{account_description}» será desenlazada"
account_specific: "Tu cuenta de %{provider} «%{account_description}» se utilizará para la autenticación."
generic: "Tu cuenta de %{provider} 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 usará para la autenticación."
activate_account:
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?"
@ -1698,7 +1698,7 @@ es:
title: "Código de invitación"
instructions: "El registro de la cuenta requiere un código de invitación"
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."
details: "Detalles"
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_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_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"
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"
@ -2794,13 +2794,13 @@ es:
aria_label: Filtrar por etiqueta
filters:
label: Solo temas/publicaciones que…
title: coincide el título únicamente
title: Coincide solo en el título
likes: me han gustado
posted: he publicado en ellos
created: Creado por mi
watching: estoy vigilando
tracking: estoy siguiendo
private: en mis mensajes
private: En mis mensajes
bookmarks: he guardado
first: son la primera publicación
pinned: están anclados
@ -4047,7 +4047,6 @@ es:
with_topics: "%{filter} temas"
with_category: "%{filter} Foro de %{category}"
filter:
title: "Filtrar"
button:
label: "Filtrar"
latest:
@ -4326,8 +4325,8 @@ es:
one: 'Esta etiqueta pertence al grupo: «%{tag_groups}»'
other: "Esta etiqueta pertence a estos grupos: %{tag_groups}."
category_restrictions:
one: "Solo se puede utilizar en esta categoría:"
other: "Solo se puede utilizar en estas categorías:"
one: "Solo se puede usar en esta categoría:"
other: "Solo se puede usar en estas categorías:"
edit_synonyms: "Editar sinónimos"
add_synonyms_label: "Añadir sinónimos:"
add_synonyms: "Añadir"
@ -5151,7 +5150,7 @@ es:
reviewable_created: "El elemento revisable está listo"
reviewable_updated: "Se ha actualizado el elemento revisable"
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_revoked: "Se ha revocado la medalla de usuario"
like_event:
@ -6417,7 +6416,7 @@ es:
<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}"
delete: "Eliminar publicaciones de @%{username}"
cancel: "Cancelar"
@ -6742,10 +6741,11 @@ es:
granted_badges: Medallas concedidas
grant: Conceder
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"
allow_title: Permitir que se use la medalla como título
multiple_grant: Se puede conceder varias veces
visibility_heading: Visibilidad
listable: Mostrar medalla en la página pública de medallas
enabled: activado
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_time: <span class="username">%{username}</span> a las <span class="time">%{time}</span>
badge_intro:
title: "Elige una insignia o crea una nueva"
description: "Empieza seleccionando una insignia existente para personalizarla, o crea una insignia totalmente nueva"
title: "Elige una medalla o crea una nueva"
description: "Empieza seleccionando una medalla existente para personalizarla, o crea una medalla totalmente nueva"
mass_award:
title: Conceder en masa
description: Concede la misma medalla a muchos usuarios a la vez.

View File

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

View File

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

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